Просмотр исходного кода

inline mode (#107)

* ai sdk

* chat

* move more to lib

* fixes

* refactor

* fixes

* streamdown

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* data-spec

* fixes

* sortable

* github

* tools

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tables

* fixes

* fixes

* rename to chat

* fixes docs

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fix lint

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
Chris Tate 4 месяцев назад
Родитель
Сommit
ddae61805e
100 измененных файлов с 8243 добавлено и 2486 удалено
  1. 24 19
      README.md
  2. 3 2
      apps/web/app/(main)/docs/a2ui/page.mdx
  3. 3 2
      apps/web/app/(main)/docs/adaptive-cards/page.mdx
  4. 3 2
      apps/web/app/(main)/docs/ag-ui/page.mdx
  5. 137 27
      apps/web/app/(main)/docs/ai-sdk/page.mdx
  6. 6 6
      apps/web/app/(main)/docs/api/codegen/page.mdx
  7. 179 44
      apps/web/app/(main)/docs/api/core/page.mdx
  8. 10 6
      apps/web/app/(main)/docs/api/react-native/page.mdx
  9. 103 25
      apps/web/app/(main)/docs/api/react/page.mdx
  10. 5 3
      apps/web/app/(main)/docs/catalog/page.mdx
  11. 6 6
      apps/web/app/(main)/docs/changelog/page.mdx
  12. 5 5
      apps/web/app/(main)/docs/code-export/page.mdx
  13. 9 17
      apps/web/app/(main)/docs/custom-schema/page.mdx
  14. 218 77
      apps/web/app/(main)/docs/data-binding/page.mdx
  15. 221 0
      apps/web/app/(main)/docs/generation-modes/page.mdx
  16. 412 0
      apps/web/app/(main)/docs/migration/page.mdx
  17. 3 2
      apps/web/app/(main)/docs/openapi/page.mdx
  18. 94 32
      apps/web/app/(main)/docs/page.mdx
  19. 18 16
      apps/web/app/(main)/docs/quick-start/page.mdx
  20. 47 33
      apps/web/app/(main)/docs/registry/page.mdx
  21. 8 6
      apps/web/app/(main)/docs/schemas/page.mdx
  22. 43 31
      apps/web/app/(main)/docs/specs/page.mdx
  23. 78 71
      apps/web/app/(main)/docs/streaming/page.mdx
  24. 28 19
      apps/web/app/(main)/docs/validation/page.mdx
  25. 264 67
      apps/web/app/(main)/docs/visibility/page.mdx
  26. 9 7
      apps/web/app/(main)/page.tsx
  27. 2 1
      apps/web/app/api/generate/route.ts
  28. 17 0
      apps/web/app/globals.css
  29. 67 125
      apps/web/components/demo.tsx
  30. 115 0
      apps/web/components/generation-modes-diagram.tsx
  31. 1 1
      apps/web/components/header.tsx
  32. 5 82
      apps/web/components/playground.tsx
  33. 15 3
      apps/web/lib/docs-navigation.ts
  34. 3 1
      apps/web/lib/page-titles.ts
  35. 117 0
      apps/web/lib/render/catalog-display.ts
  36. 61 30
      apps/web/lib/render/catalog.ts
  37. 151 99
      apps/web/lib/render/registry.tsx
  38. 50 29
      apps/web/lib/render/renderer.tsx
  39. 5 1
      apps/web/mdx-components.tsx
  40. 2 0
      apps/web/package.json
  41. 36 0
      examples/chat/app/api/generate/route.ts
  42. 128 0
      examples/chat/app/globals.css
  43. 40 0
      examples/chat/app/layout.tsx
  44. 493 0
      examples/chat/app/page.tsx
  45. 16 0
      examples/chat/components/theme-provider.tsx
  46. 36 0
      examples/chat/components/theme-toggle.tsx
  47. 73 0
      examples/chat/components/ui/accordion.tsx
  48. 66 0
      examples/chat/components/ui/alert.tsx
  49. 48 0
      examples/chat/components/ui/badge.tsx
  50. 57 0
      examples/chat/components/ui/button.tsx
  51. 92 0
      examples/chat/components/ui/card.tsx
  52. 357 0
      examples/chat/components/ui/chart.tsx
  53. 20 0
      examples/chat/components/ui/input.tsx
  54. 22 0
      examples/chat/components/ui/label.tsx
  55. 31 0
      examples/chat/components/ui/progress.tsx
  56. 39 0
      examples/chat/components/ui/radio-group.tsx
  57. 130 0
      examples/chat/components/ui/select.tsx
  58. 28 0
      examples/chat/components/ui/separator.tsx
  59. 13 0
      examples/chat/components/ui/skeleton.tsx
  60. 116 0
      examples/chat/components/ui/table.tsx
  61. 91 0
      examples/chat/components/ui/tabs.tsx
  62. 31 0
      examples/chat/eslint.config.js
  63. 158 0
      examples/chat/lib/agent.ts
  64. 589 0
      examples/chat/lib/render/catalog.ts
  65. 1072 0
      examples/chat/lib/render/registry.tsx
  66. 48 0
      examples/chat/lib/render/renderer.tsx
  67. 165 0
      examples/chat/lib/tools/crypto.ts
  68. 237 0
      examples/chat/lib/tools/github.ts
  69. 67 0
      examples/chat/lib/tools/hackernews.ts
  70. 36 0
      examples/chat/lib/tools/search.ts
  71. 126 0
      examples/chat/lib/tools/weather.ts
  72. 6 0
      examples/chat/lib/utils.ts
  73. 6 0
      examples/chat/next-env.d.ts
  74. 5 0
      examples/chat/next.config.ts
  75. 50 0
      examples/chat/package.json
  76. 5 0
      examples/chat/postcss.config.mjs
  77. 13 0
      examples/chat/tsconfig.json
  78. 1 1
      examples/dashboard/app/api/generate/route.ts
  79. 6 2
      examples/dashboard/app/api/v1/widgets/route.ts
  80. 2 1
      examples/dashboard/app/page.tsx
  81. 27 20
      examples/dashboard/lib/render/catalog.ts
  82. 58 55
      examples/dashboard/lib/render/registry.tsx
  83. 1 1
      examples/react-native/lib/render/catalog.ts
  84. 35 25
      examples/stripe-app/src/lib/render/catalog.ts
  85. 7 4
      examples/stripe-app/src/lib/render/catalog/actions.ts
  86. 49 107
      examples/stripe-app/src/lib/render/catalog/components.tsx
  87. 2 1
      examples/stripe-app/src/lib/render/renderer.tsx
  88. 3 0
      package.json
  89. 12 12
      packages/codegen/README.md
  90. 4 4
      packages/codegen/src/serialize.ts
  91. 54 4
      packages/codegen/src/traverse.test.ts
  92. 62 38
      packages/codegen/src/traverse.ts
  93. 91 16
      packages/core/README.md
  94. 3 3
      packages/core/src/actions.test.ts
  95. 2 2
      packages/core/src/actions.ts
  96. 0 717
      packages/core/src/catalog.test.ts
  97. 0 534
      packages/core/src/catalog.ts
  98. 38 26
      packages/core/src/index.ts
  99. 500 0
      packages/core/src/props.test.ts
  100. 193 16
      packages/core/src/props.ts

+ 24 - 19
README.md

@@ -79,7 +79,7 @@ const { registry } = defineRegistry(catalog, {
       </div>
     ),
     Button: ({ props, emit }) => (
-      <button onClick={() => emit?.("press")}>
+      <button onClick={() => emit("press")}>
         {props.label}
       </button>
     ),
@@ -116,15 +116,21 @@ function Dashboard({ spec }) {
 import { defineRegistry, Renderer } from "@json-render/react";
 import { schema } from "@json-render/react";
 
-// Element tree spec format
+// Flat spec format (root key + elements map)
 const spec = {
-  root: {
-    type: "Card",
-    props: { title: "Hello" },
-    children: [
-      { type: "Button", props: { label: "Click me" } }
-    ]
-  }
+  root: "card-1",
+  elements: {
+    "card-1": {
+      type: "Card",
+      props: { title: "Hello" },
+      children: ["button-1"],
+    },
+    "button-1": {
+      type: "Button",
+      props: { label: "Click me" },
+      children: [],
+    },
+  },
 };
 
 // defineRegistry creates a type-safe component registry
@@ -213,12 +219,10 @@ const systemPrompt = catalog.prompt();
 {
   "type": "Alert",
   "props": { "message": "Error occurred" },
-  "visible": {
-    "and": [
-      { "path": "/form/hasError" },
-      { "not": { "path": "/form/errorDismissed" } }
-    ]
-  }
+  "visible": [
+    { "$state": "/form/hasError" },
+    { "$state": "/form/errorDismissed", "not": true }
+  ]
 }
 ```
 
@@ -230,15 +234,15 @@ Any prop value can be data-driven using expressions:
 {
   "type": "Icon",
   "props": {
-    "name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
-    "color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
+    "name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
+    "color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
   }
 }
 ```
 
 Two expression forms:
 
-- **`{ "$path": "/state/key" }`** - reads a value from the data model
+- **`{ "$state": "/state/key" }`** - reads a value from the state model
 - **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
 
 ### Actions
@@ -248,7 +252,7 @@ Components can trigger actions, including the built-in `setState` action:
 ```json
 {
   "type": "Pressable",
-  "props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
+  "props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
   "children": ["home-icon"]
 }
 ```
@@ -269,6 +273,7 @@ pnpm dev
 - http://localhost:3000 - Docs & Playground
 - http://localhost:3001 - Example Dashboard
 - http://localhost:3002 - Remotion Video Example
+- Chat Example: run `pnpm dev` in `examples/chat`
 - React Native example: run `npx expo start` in `examples/react-native`
 
 ## How It Works

+ 3 - 2
apps/web/app/(main)/docs/a2ui/page.mdx

@@ -65,7 +65,8 @@ A2UI uses an adjacency list model - a flat list of components with ID references
 ## Define the A2UI Catalog
 
 ```typescript
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
 // A2UI BoundValue schema
@@ -83,7 +84,7 @@ const Children = z.object({
   }).optional(),
 }).refine(d => d.explicitList || d.template);
 
-export const a2uiCatalog = createCatalog({
+export const a2uiCatalog = defineCatalog(schema, {
   components: {
     Text: {
       description: 'Displays text content',

+ 3 - 2
apps/web/app/(main)/docs/adaptive-cards/page.mdx

@@ -88,7 +88,8 @@ Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards h
 Define a catalog matching the Adaptive Cards element types:
 
 ```typescript
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
 // Common Adaptive Cards properties
@@ -108,7 +109,7 @@ const BaseElement = {
   spacing: Spacing.optional(),
 };
 
-export const adaptiveCardsCatalog = createCatalog({
+export const adaptiveCardsCatalog = defineCatalog(schema, {
   components: {
     // Root card
     AdaptiveCard: {

+ 3 - 2
apps/web/app/(main)/docs/ag-ui/page.mdx

@@ -149,10 +149,11 @@ export type AGUIEvent = z.infer<typeof AGUIEvent>;
 Create a catalog for UI components that agents can render:
 
 ```typescript
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
-export const aguiCatalog = createCatalog({
+export const aguiCatalog = defineCatalog(schema, {
   components: {
     Container: {
       description: 'A container for grouping elements',

+ 137 - 27
apps/web/app/(main)/docs/ai-sdk/page.mdx

@@ -2,34 +2,37 @@ export const metadata = { title: "AI SDK Integration" }
 
 # AI SDK Integration
 
-Use json-render with the Vercel AI SDK for seamless streaming.
+Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
 
 ## Installation
 
 ```bash
-npm install ai
+npm install ai @ai-sdk/react
 ```
 
-## API Route Setup
+## Generate Mode
+
+In generate mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
+
+### API Route
 
 ```typescript
 // app/api/generate/route.ts
-import { streamText } from 'ai';
-import { catalog } from '@/lib/catalog';
+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 
+  const contextPrompt = currentTree
     ? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
-    : '';
+    : "";
 
   const result = streamText({
-    model: 'anthropic/claude-haiku-4.5',
+    model: yourModel,
     system: systemPrompt + contextPrompt,
     prompt,
   });
@@ -38,60 +41,167 @@ export async function POST(req: Request) {
 }
 ```
 
-## Client-Side Hook
+### Client
 
-Use `useUIStream` on the client:
+Use `useUIStream` on the client to compile the JSONL stream into a spec:
 
 ```tsx
-'use client';
+"use client";
 
-import { useUIStream, Renderer } from '@json-render/react';
+import { useUIStream, Renderer } from "@json-render/react";
 
 function GenerativeUI() {
   const { spec, isStreaming, error, send } = useUIStream({
-    api: '/api/generate',
+    api: "/api/generate",
   });
 
   return (
     <div>
-      <button 
-        onClick={() => send('Create a dashboard with metrics')}
+      <button
+        onClick={() => send("Create a dashboard with metrics")}
         disabled={isStreaming}
       >
-        {isStreaming ? 'Generating...' : 'Generate'}
+        {isStreaming ? "Generating..." : "Generate"}
       </button>
-      
+
       {error && <p className="text-red-500">{error.message}</p>}
-      
+
       <Renderer spec={spec} registry={registry} loading={isStreaming} />
     </div>
   );
 }
 ```
 
+## Chat Mode
+
+In chat mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
+
+### API Route
+
+Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.
+
+```typescript
+// app/api/chat/route.ts
+import { streamText } from "ai";
+import { pipeJsonRender } from "@json-render/core";
+import {
+  createUIMessageStream,
+  createUIMessageStreamResponse,
+} from "ai";
+import { catalog } from "@/lib/catalog";
+
+export async function POST(req: Request) {
+  const { messages } = await req.json();
+
+  const result = streamText({
+    model: yourModel,
+    system: catalog.prompt({ mode: "chat" }),
+    messages,
+  });
+
+  const stream = createUIMessageStream({
+    execute: async ({ writer }) => {
+      writer.merge(pipeJsonRender(result.toUIMessageStream()));
+    },
+  });
+
+  return createUIMessageStreamResponse({ stream });
+}
+```
+
+### Client
+
+Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message:
+
+```tsx
+"use client";
+
+import { useChat } from "@ai-sdk/react";
+import { useJsonRenderMessage, Renderer } from "@json-render/react";
+
+function Chat() {
+  const { messages, input, handleInputChange, handleSubmit } = useChat({
+    api: "/api/chat",
+  });
+
+  return (
+    <div>
+      <div>
+        {messages.map((msg) => (
+          <ChatMessage key={msg.id} message={msg} />
+        ))}
+      </div>
+
+      <form onSubmit={handleSubmit}>
+        <input
+          value={input}
+          onChange={handleInputChange}
+          placeholder="Ask something..."
+        />
+        <button type="submit">Send</button>
+      </form>
+    </div>
+  );
+}
+
+function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
+  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
+
+  return (
+    <div>
+      {text && <p>{text}</p>}
+      {hasSpec && spec && (
+        <Renderer spec={spec} registry={registry} />
+      )}
+    </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
+- Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
 - Includes examples for better generation
 
-## Custom System Prompts
+### Custom Rules
 
 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"',
+    "Always use Card components for grouping related content",
+    "Prefer horizontal layouts (Row) for metrics",
+    "Use consistent spacing with padding=\"md\"",
   ],
 });
 ```
 
+### Chat Mode Prompt
+
+```typescript
+const chatPrompt = catalog.prompt({ mode: "chat" });
+```
+
+In chat mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
+
+## Which Mode?
+
+| | Generate | Chat |
+|---|---|---|
+| Output | JSONL only | Text + JSONL |
+| Text-only replies | No | Yes |
+| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
+| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
+| Use case | Playgrounds, builders | Chatbots, copilots |
+
+Learn more in the [Generation Modes](/docs/generation-modes) guide.
+
 ## Next
 
-Learn about [progressive streaming](/docs/streaming).
+- Learn about [progressive streaming](/docs/streaming)
+- See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation

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

@@ -13,12 +13,12 @@ Walk the UI spec depth-first.
 ```typescript
 function traverseSpec(
   spec: Spec,
-  visitor: SpecVisitor,
+  visitor: TreeVisitor,
   startKey?: string
 ): void
 
-interface SpecVisitor {
-  (element: UIElement, depth: number, parent: UIElement | null): void;
+interface TreeVisitor {
+  (element: UIElement, key: string, depth: number, parent: UIElement | null): void;
 }
 ```
 
@@ -36,7 +36,7 @@ const components = collectUsedComponents(spec);
 
 ### collectStatePaths
 
-Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
+Get all state paths referenced in props (statePath, bindPath, etc.).
 
 ```typescript
 function collectStatePaths(spec: Spec): Set<string>
@@ -77,8 +77,8 @@ serializePropValue("hello")
 serializePropValue(42)
 // { value: '42', needsBraces: true }
 
-serializePropValue({ path: 'user/name' })
-// { value: '{ path: "user/name" }', needsBraces: true }
+serializePropValue({ $state: '/user/name' })
+// { value: '{ $state: "/user/name" }', needsBraces: true }
 ```
 
 ### serializeProps

+ 179 - 44
apps/web/app/(main)/docs/api/core/page.mdx

@@ -72,8 +72,9 @@ interface Catalog {
 }
 
 interface PromptOptions {
-  system?: string;        // Custom system message intro
-  customRules?: string[]; // Additional rules to append
+  system?: string;           // Custom system message intro
+  customRules?: string[];    // Additional rules to append
+  mode?: "generate" | "chat"; // Output mode (default: "generate")
 }
 
 interface SpecValidationResult<T> {
@@ -203,28 +204,25 @@ Pre-built Zod schemas for common json-render types:
 
 ```typescript
 import {
-  DynamicValueSchema,    // string | number | boolean | null | { path: string }
-  DynamicStringSchema,   // string | { path: string }
-  DynamicNumberSchema,   // number | { path: string }
-  DynamicBooleanSchema,  // boolean | { path: string }
+  DynamicValueSchema,    // string | number | boolean | null | { $state: string }
+  DynamicStringSchema,   // string | { $state: string }
+  DynamicNumberSchema,   // number | { $state: string }
+  DynamicBooleanSchema,  // boolean | { $state: string }
 } from '@json-render/core';
 
-// Dynamic values can be literals or data path references
-type DynamicValue<T> = T | { path: string };
+// Dynamic values can be literals or state path references
+type DynamicValue<T> = T | { $state: string };
 
-// Example: a prop that can be a literal or bound to data
+// Example: a prop that can be a literal or bound to state
 const schema = z.object({
-  label: DynamicStringSchema,  // "Hello" or { path: "/user/name" }
+  label: DynamicStringSchema,  // "Hello" or { $state: "/user/name" }
 });
 ```
 
-### Visibility & Logic Schemas
+### Visibility Schemas
 
 ```typescript
-import {
-  VisibilityConditionSchema,  // Full visibility condition
-  LogicExpressionSchema,      // Logic operators (and, or, not, eq, gt, etc.)
-} from '@json-render/core';
+import { VisibilityConditionSchema } from '@json-render/core';
 
 // Use in component props that need conditional rendering
 const schema = z.object({
@@ -304,6 +302,74 @@ const obj = {};
 applySpecStreamPatch(obj, patch);
 ```
 
+### applySpecPatch
+
+Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):
+
+```typescript
+import { applySpecPatch } from '@json-render/core';
+
+let spec: Spec = { root: "", elements: {} };
+applySpecPatch(spec, { op: "add", path: "/root", value: "main" });
+
+// For React state updates, spread to create a new reference:
+setSpec({ ...applySpecPatch(spec, patch) });
+```
+
+### nestedToFlat
+
+Convert a nested element tree (with inline children) into the flat `Spec` format:
+
+```typescript
+import { nestedToFlat } from '@json-render/core';
+
+const flat = nestedToFlat({
+  type: "Card",
+  props: { title: "Hello" },
+  children: [
+    { type: "Text", props: { content: "World" }, children: [] }
+  ],
+});
+// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
+```
+
+### createMixedStreamParser
+
+Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
+
+```typescript
+import { createMixedStreamParser } from '@json-render/core';
+
+const parser = createMixedStreamParser({
+  onText: (text) => appendToMessage(text),
+  onPatch: (patch) => applySpecPatch(spec, patch),
+});
+
+// As chunks arrive from the stream:
+for await (const chunk of stream) {
+  parser.push(chunk);
+}
+parser.flush();
+```
+
+### pipeJsonRender
+
+Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Chat mode API routes.
+
+```typescript
+import { pipeJsonRender } from '@json-render/core';
+import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';
+
+const stream = createUIMessageStream({
+  execute: async ({ writer }) => {
+    writer.merge(pipeJsonRender(result.toUIMessageStream()));
+  },
+});
+return createUIMessageStreamResponse({ stream });
+```
+
+See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
+
 ### SpecStream Types
 
 Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
@@ -322,6 +388,16 @@ interface SpecStreamCompiler<T> {
   getPatches(): SpecStreamLine[];
   reset(): void;
 }
+
+interface MixedStreamCallbacks {
+  onText: (text: string) => void;
+  onPatch: (patch: SpecStreamLine) => void;
+}
+
+interface MixedStreamParser {
+  push(chunk: string): void;
+  flush(): void;
+}
 ```
 
 ## Utility Functions
@@ -332,10 +408,10 @@ interface SpecStreamCompiler<T> {
 import { getByPath, setByPath } from '@json-render/core';
 
 // Get value by JSON Pointer path
-const value = getByPath(data, '/user/name');  // "Alice"
+const value = getByPath(state, '/user/name');  // "Alice"
 
 // Set value by path (mutates object)
-setByPath(data, '/user/email', 'alice@example.com');
+setByPath(state, '/user/email', 'alice@example.com');
 ```
 
 ### resolveDynamicValue
@@ -343,9 +419,9 @@ setByPath(data, '/user/email', 'alice@example.com');
 ```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"
+// Resolve a dynamic value against state
+const name = resolveDynamicValue("Hello", state);        // "Hello"
+const name2 = resolveDynamicValue({ $state: "/user/name" }, state);  // "Alice"
 ```
 
 ### findFormValue
@@ -354,32 +430,88 @@ const name2 = resolveDynamicValue({ path: "/user/name" }, data);  // "Alice"
 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);
+// Checks: params.name, params["form.name"], state["form.name"], state.form.name
+const value = findFormValue("name", params, state);
+```
+
+## buildUserPrompt
+
+Build structured user prompts for AI generation, with support for refinement and state context.
+
+```typescript
+import { buildUserPrompt } from '@json-render/core';
+
+function buildUserPrompt(options: UserPromptOptions): string
+
+interface UserPromptOptions {
+  prompt: string;                        // The user's text prompt
+  currentSpec?: Spec | null;             // Existing spec to refine (triggers patch-only mode)
+  state?: Record<string, unknown> | null; // Runtime state context to include
+  maxPromptLength?: number;              // Max length for user text (truncates before wrapping)
+}
+```
+
+### Fresh generation
+
+```typescript
+const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
+```
+
+### Refinement (patch-only mode)
+
+When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
+
+```typescript
+const userPrompt = buildUserPrompt({
+  prompt: "add a dark mode toggle",
+  currentSpec: existingSpec,
+});
+```
+
+### With state context
+
+Include runtime state so the AI knows what data is available:
+
+```typescript
+const userPrompt = buildUserPrompt({
+  prompt: "show my data",
+  state: { todos: [{ text: "Buy milk" }] },
+});
 ```
 
 ## evaluateVisibility
 
-Evaluates a visibility condition against data and auth state.
+Evaluates a visibility condition against the state model.
 
 ```typescript
 function evaluateVisibility(
   condition: VisibilityCondition | undefined,
-  data: Record<string, unknown>,
-  auth?: AuthState
+  ctx: VisibilityContext
 ): boolean
 
+interface VisibilityContext {
+  stateModel: StateModel;
+  repeatItem?: unknown;    // Current repeat item (inside repeat scope)
+  repeatIndex?: number;    // Current repeat array index (inside repeat scope)
+}
+
 type VisibilityCondition =
-  | { path: string }
-  | { auth: 'signedIn' | 'signedOut' | string }
-  | { and: VisibilityCondition[] }
-  | { or: VisibilityCondition[] }
-  | { not: VisibilityCondition }
-  | { eq: [DynamicValue, DynamicValue] }
-  | { gt: [DynamicValue, DynamicValue] }
-  | { gte: [DynamicValue, DynamicValue] }
-  | { lt: [DynamicValue, DynamicValue] }
-  | { lte: [DynamicValue, DynamicValue] };
+  | { $state: string }                                    // truthiness
+  | { $state: string; not: true }                         // falsy
+  | { $state: string; eq: unknown }                       // equality
+  | { $state: string; neq: unknown }                      // inequality
+  | { $state: string; gt: number }                        // greater than
+  | { $state: string; gte: number }                       // gte
+  | { $state: string; lt: number }                        // lt
+  | { $state: string; lte: number }                       // lte
+  | { $item: string }                                     // item field (repeat scope)
+  | { $item: string; eq: unknown }                        // item field equality
+  | { $index: true }                                      // index truthiness (repeat scope)
+  | { $index: true; gt: number }                          // index comparison
+  | VisibilityCondition[]                                 // implicit AND
+  | { $and: VisibilityCondition[] }                       // explicit AND
+  | { $or: VisibilityCondition[] }                        // OR
+  | boolean;                                              // always / never
 ```
 
 ## Types
@@ -388,32 +520,35 @@ type VisibilityCondition =
 
 ```typescript
 interface UIElement {
-  key: string;
   type: string;
   props: Record<string, unknown>;
   children?: string[];          // Keys of child elements
   visible?: VisibilityCondition;
-  validation?: ValidationSchema;
+  on?: Record<string, ActionBinding | ActionBinding[]>;  // Event bindings
+  repeat?: { statePath: string; key?: string };           // Repeat for arrays
 }
 ```
 
+Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself.
+
 ### Spec (Element Tree)
 
 ```typescript
 interface Spec {
-  root: string | null;         // Key of root element
-  elements: Record<string, UIElement>;
+  root: string | null;                        // Key of root element
+  elements: Record<string, UIElement>;        // Flat element map
+  state?: Record<string, unknown>;            // Initial state model
 }
 ```
 
 Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
 
-### Action
+### ActionBinding
 
 ```typescript
-interface Action {
-  name: string;
-  params?: Record<string, unknown>;
+interface ActionBinding {
+  action: string;
+  params?: Record<string, DynamicValue>;
   confirm?: {
     title: string;
     message: string;
@@ -433,7 +568,7 @@ interface ValidationSchema {
 }
 
 interface ValidationCheck {
-  fn: string;
+  type: string;
   args?: Record<string, unknown>;
   message: string;
 }

+ 10 - 6
apps/web/app/(main)/docs/api/react-native/page.mdx

@@ -36,11 +36,11 @@ React Native renderer with standard components, providers, and hooks.
 | Component | Props | Description |
 |-----------|-------|-------------|
 | `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
-| `TextInput` | `placeholder`, `statePath`, `secure`, `keyboardType`, `multiline` | Text input field |
-| `Switch` | `statePath`, `label` | Toggle switch |
-| `Checkbox` | `statePath`, `label` | Checkbox with label |
-| `Slider` | `statePath`, `min`, `max`, `step` | Range slider |
-| `SearchBar` | `placeholder`, `statePath` | Search input |
+| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
+| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
+| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
+| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
+| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
 
 ### Feedback
 
@@ -83,6 +83,8 @@ React Native renderer with standard components, providers, and hooks.
 </VisibilityProvider>
 ```
 
+Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
+
 ### ValidationProvider
 
 ```tsx
@@ -135,7 +137,9 @@ const { state, get, set, update } = useStateStore();
 const value = useStateValue(path: string);
 ```
 
-### useStateBinding
+### useStateBinding (deprecated)
+
+> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
 
 ```typescript
 const [value, setValue] = useStateBinding(path: string);

+ 103 - 25
apps/web/app/(main)/docs/api/react/page.mdx

@@ -27,24 +27,21 @@ type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
 ### VisibilityProvider
 
 ```tsx
-<VisibilityProvider auth={AuthState}>
+<VisibilityProvider>
   {children}
 </VisibilityProvider>
-
-interface AuthState {
-  isSignedIn: boolean;
-  roles?: string[];
-}
 ```
 
+`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
+
 ### ValidationProvider
 
 ```tsx
-<ValidationProvider functions={Record<string, ValidatorFn>}>
+<ValidationProvider customFunctions={Record<string, ValidationFunction>}>
   {children}
 </ValidationProvider>
 
-type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
+type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
 ```
 
 ## defineRegistry
@@ -58,7 +55,7 @@ const { registry } = defineRegistry(catalog, {
   components: {
     Card: ({ props, children }) => <div>{props.title}{children}</div>,
     Button: ({ props, emit }) => (
-      <button onClick={() => emit?.("press")}>
+      <button onClick={() => emit("press")}>
         {props.label}
       </button>
     ),
@@ -88,10 +85,11 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
 
 ```tsx
 interface ComponentContext<P> {
-  props: P;                    // Typed props from catalog
-  children?: React.ReactNode;  // Rendered children (for slot components)
-  emit?: (event: string) => void;  // Emit a named event
+  props: P;                                // Typed props from catalog
+  children?: React.ReactNode;              // Rendered children (for slot components)
+  emit: (event: string) => void;           // Emit a named event
   loading?: boolean;
+  bindings?: Record<string, string>;       // State paths from $bindState/$bindItem expressions
 }
 ```
 
@@ -117,10 +115,10 @@ const {
 
 ```typescript
 const {
-  data,      // Record<string, unknown>
-  setState,   // (data: object) => void
-  getValue,  // (path: string) => unknown
-  setValue,  // (path: string, value: unknown) => void
+  state,   // StateModel (Record<string, unknown>)
+  get,     // (path: string) => unknown
+  set,     // (path: string, value: unknown) => void
+  update,  // (updates: Record<string, unknown>) => void
 } = useStateStore();
 ```
 
@@ -130,7 +128,9 @@ const {
 const value = useStateValue(path: string);
 ```
 
-### useStateBinding
+### useStateBinding (deprecated)
+
+> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
 
 ```typescript
 const [value, setValue] = useStateBinding(path: string);
@@ -139,15 +139,15 @@ const [value, setValue] = useStateBinding(path: string);
 ### useActions
 
 ```typescript
-const { dispatch } = useActions();
-// dispatch(actionName: string, params: object)
+const { execute } = useActions();
+// execute(binding: ActionBinding) => Promise<void>
 ```
 
 ### useAction
 
 ```typescript
-const submitForm = useAction('submit_form');
-// submitForm(params: object)
+const { execute, isLoading } = useAction(binding: ActionBinding);
+// execute() => Promise<void>
 ```
 
 ### useIsVisible
@@ -160,10 +160,88 @@ const isVisible = useIsVisible(condition?: VisibilityCondition);
 
 ```typescript
 const {
-  value,     // unknown
-  setValue,  // (value: unknown) => void
+  state,     // FieldValidationState
+  validate,  // () => ValidationResult
+  touch,     // () => void
+  clear,     // () => void
   errors,    // string[]
-  validate,  // () => Promise<boolean>
   isValid,   // boolean
-} = useFieldValidation(path: string, checks: ValidationCheck[]);
+} = useFieldValidation(path: string, config?: ValidationConfig);
+```
+
+`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
+
+### useBoundProp
+
+Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
+
+```typescript
+const [value, setValue] = useBoundProp<T>(
+  propValue: T | undefined,       // The already-resolved prop value
+  bindingPath: string | undefined  // From bindings?.value
+);
+```
+
+Use inside registry components:
+
+```tsx
+const Input: ComponentRenderer = ({ props, bindings }) => {
+  const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
+  return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
+};
+```
+
+### Chat Hooks
+
+Two hooks are available for chat + GenUI, depending on your setup:
+
+- **`useChatUI`** -- Self-contained chat hook with its own message state, fetch logic, and mixed stream parsing. Use when you want a standalone chat experience without the Vercel AI SDK.
+- **`useJsonRenderMessage`** -- Extracts spec + text from an AI SDK `UIMessage.parts` array. Use with the Vercel AI SDK's `useChat` for full AI SDK integration.
+
+### useChatUI
+
+Hook for chat + GenUI experiences. Manages a multi-turn conversation where each assistant message can contain both text and a json-render UI spec.
+
+```typescript
+const {
+  messages,     // ChatMessage[] - all messages in the conversation
+  isStreaming,  // boolean - true while streaming
+  error,        // Error | null
+  send,         // (text: string) => Promise<void>
+  clear,        // () => void - reset conversation
+} = useChatUI({
+  api: string,                                   // API endpoint
+  onComplete?: (message: ChatMessage) => void,   // Called when streaming completes
+  onError?: (error: Error) => void,              // Called on error
+});
+
+interface ChatMessage {
+  id: string;
+  role: "user" | "assistant";
+  text: string;
+  spec: Spec | null;
+}
+```
+
+### useJsonRenderMessage
+
+Extract a spec and text content from an AI SDK message's `parts` array. Designed for integration with Vercel AI SDK's `useChat`.
+
+```typescript
+const { spec, text, hasSpec } = useJsonRenderMessage(parts: DataPart[]);
+
+// spec: Spec | null     - compiled from JSONL patches in data parts
+// text: string          - concatenated text parts
+// hasSpec: boolean      - true when spec is non-null
+```
+
+### buildSpecFromParts / getTextFromParts
+
+Standalone utilities for extracting spec and text from AI SDK message parts (non-hook versions):
+
+```typescript
+import { buildSpecFromParts, getTextFromParts } from '@json-render/react';
+
+const spec = buildSpecFromParts(message.parts);   // Spec | null
+const text = getTextFromParts(message.parts);      // string
 ```

+ 5 - 3
apps/web/app/(main)/docs/catalog/page.mdx

@@ -6,7 +6,7 @@ The catalog defines what AI can generate. It's your guardrail.
 
 ## What is a Catalog?
 
-A catalog is a schema that defines:
+A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defines the grammar (how specs are structured), the catalog defines the vocabulary (what components and actions are available). It lists:
 
 - **Components** — UI elements AI can create (with props and optional slots)
 - **Actions** — Operations AI can trigger
@@ -14,9 +14,11 @@ A catalog is a schema that defines:
 
 ## Creating a Catalog
 
+`defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic.
+
 ```typescript
 import { defineCatalog } from '@json-render/core';
-import { schema } from '@json-render/react';
+import { schema } from '@json-render/react'; // or '@json-render/react-native'
 import { z } from 'zod';
 
 const catalog = defineCatalog(schema, {
@@ -35,7 +37,7 @@ const catalog = defineCatalog(schema, {
     Metric: {
       props: z.object({
         label: z.string(),
-        valuePath: z.string(), // JSON Pointer to data
+        value: z.union([z.string(), z.number()]),
         format: z.enum(['currency', 'percent', 'number']),
       }),
       description: "Display a single metric value",

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

@@ -40,7 +40,7 @@ Components now use `emit` to fire named events instead of directly dispatching a
 ```tsx
 // Component emits a named event
 Button: ({ props, emit }) => (
-  <button onClick={() => emit?.("press")}>{props.label}</button>
+  <button onClick={() => emit("press")}>{props.label}</button>
 ),
 
 // Element spec maps events to actions
@@ -53,12 +53,12 @@ Button: ({ props, emit }) => (
 
 ### New: Repeat/List Rendering
 
-Elements can now iterate over state arrays using the `repeat` field. Child elements use `$item` and `$index` tokens in `$path` expressions to reference the current item.
+Elements can now iterate over state arrays using the `repeat` field. Child elements use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current array index.
 
 ```json
 {
   "type": "Column",
-  "repeat": { "path": "/posts", "key": "id" },
+  "repeat": { "statePath": "/posts", "key": "id" },
   "children": ["post-card"]
 }
 ```
@@ -66,7 +66,7 @@ Elements can now iterate over state arrays using the `repeat` field. Child eleme
 ```json
 {
   "type": "Card",
-  "props": { "title": { "$path": "$item/title" } }
+  "props": { "title": { "$item": "title" } }
 }
 ```
 
@@ -94,13 +94,13 @@ Validate spec structure and auto-fix common issues:
 ```typescript
 import { validateSpec, autoFixSpec } from "@json-render/core";
 
-const { valid, issues } = validateSpec(spec, catalog);
+const { valid, issues } = validateSpec(spec);
 const fixed = autoFixSpec(spec);
 ```
 
 ### Improved: State Management
 
-`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$path` expressions, and the built-in `setState` action updates state directly.
+`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$state` expressions, and the built-in `setState` action updates state directly.
 
 ### Improved: AI Prompts
 

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

@@ -70,12 +70,12 @@ The exported components are standalone with no json-render dependencies. They re
 // Generated component (standalone)
 interface MetricProps {
   label: string;
-  valuePath: string;
+  statePath: string;
   data?: Record<string, unknown>;
 }
 
-export function Metric({ label, valuePath, data }: MetricProps) {
-  const value = data ? getByPath(data, valuePath) : undefined;
+export function Metric({ label, statePath, data }: MetricProps) {
+  const value = data ? getByPath(data, statePath) : undefined;
   return (
     <div>
       <span>{label}</span>
@@ -92,8 +92,8 @@ export function Metric({ label, valuePath, data }: MetricProps) {
 ```typescript
 import { traverseSpec } from '@json-render/codegen';
 
-traverseSpec(spec, (element, depth, parent) => {
-  console.log(' '.repeat(depth * 2) + element.type);
+traverseSpec(spec, (element, key, depth, parent) => {
+  console.log(' '.repeat(depth * 2) + `${key}: ${element.type}`);
 });
 ```
 

+ 9 - 17
apps/web/app/(main)/docs/custom-schema/page.mdx

@@ -41,13 +41,13 @@ Start by defining the JSON structure your system will use. Here's an example of
 
 ## 2. Create the Catalog
 
-Define a catalog that describes your components and validates props:
+Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog).
 
 ```typescript
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
 import { z } from 'zod';
 
-export const dashboardCatalog = createCatalog({
+export const dashboardCatalog = defineCatalog(mySchema, {
   components: {
     metric: {
       description: 'Displays a single metric value',
@@ -240,11 +240,9 @@ const response = await generateText({
 
 ## 6. Validate Specs
 
-Validate incoming specs against your schema:
+Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema:
 
 ```typescript
-import { validate } from '@json-render/core';
-
 function validateDashboard(spec: unknown) {
   // Validate root structure
   const rootResult = DashboardSchema.safeParse(spec);
@@ -252,19 +250,13 @@ function validateDashboard(spec: unknown) {
     return { valid: false, errors: rootResult.error.errors };
   }
 
-  // Validate each widget against catalog
-  const errors: string[] = [];
-  for (const widget of rootResult.data.widgets) {
-    const result = validate(
-      { type: widget.type, props: widget },
-      dashboardCatalog
-    );
-    if (!result.valid) {
-      errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
-    }
+  // Validate each widget's props against the catalog
+  const result = dashboardCatalog.validate(spec);
+  if (!result.success) {
+    return { valid: false, errors: result.error.errors };
   }
 
-  return { valid: errors.length === 0, errors };
+  return { valid: true, errors: [] };
 }
 ```
 

+ 218 - 77
apps/web/app/(main)/docs/data-binding/page.mdx

@@ -2,123 +2,264 @@ export const metadata = { title: "Data Binding" }
 
 # Data Binding
 
-Connect UI components to your application data using JSON Pointer paths.
+Connect UI elements to dynamic data using expressions in your JSON specs.
 
-## JSON Pointer Paths
+## State Model
 
-json-render uses JSON Pointer (RFC 6901) for data paths:
+Every spec can include a `state` object that holds the data your UI reads from:
 
 ```json
-// Given this data:
 {
-  "user": {
-    "name": "Alice",
-    "email": "alice@example.com"
+  "root": "greeting",
+  "elements": {
+    "greeting": {
+      "type": "Text",
+      "props": { "content": { "$state": "/user/name" } },
+      "children": []
+    }
   },
-  "metrics": {
-    "revenue": 125000,
-    "growth": 0.15
+  "state": {
+    "user": { "name": "Alice" }
   }
 }
+```
+
+State can also be provided programmatically at runtime. In `@json-render/react`, this is done via `StateProvider` and hooks like `useStateStore`. See the [React API reference](/docs/api/react) for details.
+
+## JSON Pointer Paths
+
+All paths in json-render follow JSON Pointer (RFC 6901). A path is a string of `/`-separated tokens starting from the root:
 
-// These paths access:
-"/user/name"        -> "Alice"
-"/metrics/revenue"  -> 125000
-"/metrics/growth"   -> 0.15
 ```
+Given this state:
+{
+  "user": { "name": "Alice", "email": "alice@example.com" },
+  "todos": [
+    { "title": "Buy milk", "done": false },
+    { "title": "Walk dog", "done": true }
+  ]
+}
 
-## StateProvider
+"/user/name"      -> "Alice"
+"/user/email"     -> "alice@example.com"
+"/todos/0/title"  -> "Buy milk"
+"/todos/1/done"   -> true
+```
 
-Wrap your app with StateProvider to enable data binding:
+## Expressions
 
-```tsx
-import { StateProvider } from '@json-render/react';
+Expressions are special objects you place in props to read dynamic values instead of hardcoding them. There are six expression types.
 
-function App() {
-  const initialState = {
-    user: { name: 'Alice' },
-    form: { email: '', message: '' },
-  };
+### `$state` — Read from state
 
-  return (
-    <StateProvider initialState={initialState}>
-      {/* Your UI */}
-    </StateProvider>
-  );
+Use `{ "$state": "/path" }` in any prop to read a value from the state model:
+
+```json
+{
+  "type": "Card",
+  "props": {
+    "title": { "$state": "/user/name" },
+    "subtitle": { "$state": "/user/email" }
+  },
+  "children": []
 }
 ```
 
-## Reading Data
+If state contains `{ "user": { "name": "Alice", "email": "alice@example.com" } }`, the Card renders with title "Alice" and subtitle "alice@example.com".
 
-Use `useStateValue` for read-only access:
+### `$item` — Read from the current repeat item
 
-```tsx
-import { useStateValue } from '@json-render/react';
+Use `{ "$item": "field" }` inside a [repeat](#repeat) to read a field from the current array item:
 
-function UserGreeting() {
-  const name = useStateValue('/user/name');
-  return <h1>Hello, {name}!</h1>;
+```json
+{
+  "type": "Text",
+  "props": { "content": { "$item": "title" } },
+  "children": []
 }
 ```
 
-## Two-Way Binding
+Use `{ "$item": "" }` to get the entire item object.
 
-Use `useStateBinding` for read-write access:
+### `$index` — Current repeat index
 
-```tsx
-import { useStateBinding } from '@json-render/react';
+Use `{ "$index": true }` inside a [repeat](#repeat) to get the current array index (zero-based number):
 
-function EmailInput() {
-  const [email, setEmail] = useStateBinding('/form/email');
-  
-  return (
-    <input
-      type="email"
-      value={email || ''}
-      onChange={(e) => setEmail(e.target.value)}
-    />
-  );
+```json
+{
+  "type": "Text",
+  "props": { "content": { "$index": true } },
+  "children": []
 }
 ```
 
-## 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>;
+## Repeat
+
+The `repeat` field on an element renders its children once per item in a state array. It is a top-level field on the element, sibling of `type`, `props`, and `children` — not inside `props`.
+
+```json
+{
+  "root": "todo-list",
+  "elements": {
+    "todo-list": {
+      "type": "Column",
+      "props": { "gap": 8 },
+      "repeat": { "statePath": "/todos", "key": "id" },
+      "children": ["todo-item"]
+    },
+    "todo-item": {
+      "type": "Card",
+      "props": {
+        "title": { "$item": "title" },
+        "subtitle": { "$item": "description" }
+      },
+      "children": []
+    }
+  },
+  "state": {
+    "todos": [
+      { "id": "1", "title": "Buy milk", "description": "2% or whole" },
+      { "id": "2", "title": "Walk dog", "description": "Around the park" }
+    ]
+  }
 }
 ```
 
-## In JSON UI Trees
+- `repeat.statePath` — JSON Pointer to the state array
+- `repeat.key` — field name on each item to use as a stable key for rendering
+
+Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
+
+## Two-Way Binding with `$bindState`
 
-AI can reference data paths in component props:
+Form components use `{ "$bindState": "/path" }` on their natural value prop for two-way binding. The component reads from and writes to the state path.
+
+### Value prop (text inputs)
 
 ```json
 {
-  "type": "Metric",
+  "type": "TextInput",
   "props": {
-    "label": "Total Revenue",
-    "valuePath": "/metrics/revenue",
-    "format": "currency"
-  }
+    "value": { "$bindState": "/form/email" },
+    "placeholder": "Enter your email"
+  },
+  "children": []
+}
+```
+
+### Checked prop (switches, checkboxes)
+
+```json
+{
+  "type": "Switch",
+  "props": {
+    "label": "Enable notifications",
+    "checked": { "$bindState": "/settings/notifications" }
+  },
+  "children": []
+}
+```
+
+### Pressed prop (toggle buttons)
+
+```json
+{
+  "type": "ToggleButton",
+  "props": {
+    "label": "Bold",
+    "pressed": { "$bindState": "/editor/bold" }
+  },
+  "children": []
+}
+```
+
+## Two-Way Binding with `$bindItem`
+
+Inside a repeat scope, use `{ "$bindItem": "field" }` to bind to a field on the current item:
+
+```json
+{
+  "type": "Switch",
+  "props": {
+    "label": "Done",
+    "checked": { "$bindItem": "completed" }
+  },
+  "children": []
 }
 ```
 
+Use `{ "$bindItem": "" }` to bind to the entire item.
+
+`statePath` is not used for component binding. It remains for `repeat.statePath` (array iteration path) and action params like `setState.statePath` (target path for mutations).
+
+## Conditional Props
+
+Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
+
+```json
+{
+  "type": "Badge",
+  "props": {
+    "label": {
+      "$cond": { "$state": "/user/isAdmin" },
+      "$then": "Admin",
+      "$else": "Member"
+    }
+  },
+  "children": []
+}
+```
+
+The condition uses the same [visibility](/docs/visibility) expression format.
+
+## Quick Reference
+
+<div className="my-6 overflow-x-auto">
+  <table className="mdx-table w-full text-sm border-collapse">
+    <thead>
+      <tr>
+        <th>Expression</th>
+        <th>Syntax</th>
+        <th>Context</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr>
+        <td><code>{"$state"}</code></td>
+        <td><code>{'{ "$state": "/path" }'}</code></td>
+        <td>Anywhere</td>
+      </tr>
+      <tr>
+        <td><code>{"$item"}</code></td>
+        <td><code>{'{ "$item": "field" }'}</code></td>
+        <td>Inside repeat only</td>
+      </tr>
+      <tr>
+        <td><code>{"$index"}</code></td>
+        <td><code>{'{ "$index": true }'}</code></td>
+        <td>Inside repeat only</td>
+      </tr>
+      <tr>
+        <td><code>{"$cond"}</code></td>
+        <td><code>{'{ "$cond": ..., "$then": ..., "$else": ... }'}</code></td>
+        <td>Anywhere</td>
+      </tr>
+      <tr>
+        <td><code>{"$bindState"}</code></td>
+        <td><code>{'{ "$bindState": "/path" }'}</code></td>
+        <td>Form components (value, checked, pressed)</td>
+      </tr>
+      <tr>
+        <td><code>{"$bindItem"}</code></td>
+        <td><code>{'{ "$bindItem": "field" }'}</code></td>
+        <td>Form components inside repeat</td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+
 ## Next
 
-Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
+- [Visibility](/docs/visibility) — conditionally show or hide elements
+- [Action handlers](/docs/registry#action-handlers) — respond to user interactions
+- [React API reference](/docs/api/react) — React-specific hooks for programmatic state access

+ 221 - 0
apps/web/app/(main)/docs/generation-modes/page.mdx

@@ -0,0 +1,221 @@
+export const metadata = { title: "Generation Modes" }
+
+# Generation Modes
+
+json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
+
+The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
+
+<GenerationModesDiagram />
+
+## Generate Mode (Standalone)
+
+In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
+
+This is the default mode and is ideal for:
+
+- Playground and builder tools
+- Form generators
+- Dashboard builders
+- Any UI where the generated interface is the whole response
+
+### Setup
+
+```typescript
+import { streamText } from "ai";
+
+// Generate mode is the default (no mode option needed)
+const systemPrompt = catalog.prompt({
+  customRules: [
+    "Use Card as root for forms and small UIs.",
+    "Use Grid for multi-column layouts.",
+  ],
+});
+
+const result = streamText({
+  model: "anthropic/claude-haiku-4.5",
+  system: systemPrompt,
+  prompt: userPrompt,
+});
+```
+
+### Client
+
+On the client, use `useUIStream` from `@json-render/react` or the lower-level `createSpecStreamCompiler` from `@json-render/core` to compile the JSONL stream into a spec:
+
+```tsx
+import { useUIStream } from "@json-render/react";
+
+function Playground() {
+  const { spec, isStreaming, send } = useUIStream({
+    api: "/api/generate",
+  });
+
+  return (
+    <Renderer
+      spec={spec}
+      registry={registry}
+      loading={isStreaming}
+    />
+  );
+}
+```
+
+### Example output
+
+The AI outputs only JSONL — one patch per line, no surrounding text:
+
+```
+{"op":"add","path":"/root","value":"card-1"}
+{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Sign In"},"children":["email","password","submit"]}}
+{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email"}}}
+{"op":"add","path":"/elements/password","value":{"type":"Input","props":{"label":"Password","name":"password","type":"password"}}}
+{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
+```
+
+## Chat Mode (Inline)
+
+In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
+
+This is ideal for:
+
+- AI chatbots with rich UI responses
+- Copilot experiences
+- Educational assistants
+- Any conversational interface where generated UI is embedded in chat messages
+
+### Setup
+
+```typescript
+import { streamText } from "ai";
+import { pipeJsonRender } from "@json-render/core";
+import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
+
+// Enable chat mode
+const systemPrompt = catalog.prompt({ mode: "chat" });
+
+const result = streamText({
+  model: yourModel,
+  system: systemPrompt,
+  messages,
+});
+
+// In your API route, pipe the stream through pipeJsonRender
+// to separate text from JSONL patches
+const stream = createUIMessageStream({
+  execute: async ({ writer }) => {
+    writer.merge(pipeJsonRender(result.toUIMessageStream()));
+  },
+});
+
+return createUIMessageStreamResponse({ stream });
+```
+
+`pipeJsonRender` inspects each line of the AI's response. Lines that parse as JSONL patches are emitted as `data-spec` parts (which the renderer picks up). Everything else is passed through as text.
+
+### Client
+
+On the client, use `useJsonRenderMessage` from `@json-render/react` to extract the spec from a chat message's parts:
+
+```tsx
+import { useChat } from "@ai-sdk/react";
+import { useJsonRenderMessage } from "@json-render/react";
+
+function Chat() {
+  const { messages, input, handleInputChange, handleSubmit } = useChat();
+
+  return (
+    <div>
+      {messages.map((msg) => (
+        <ChatMessage key={msg.id} message={msg} />
+      ))}
+      {/* input form */}
+    </div>
+  );
+}
+
+function ChatMessage({ message }) {
+  const { spec } = useJsonRenderMessage(message.parts);
+
+  return (
+    <div>
+      {/* Render text parts */}
+      {message.parts
+        .filter((p) => p.type === "text")
+        .map((p, i) => <p key={i}>{p.text}</p>)}
+
+      {/* Render the generated UI inline */}
+      {spec && (
+        <Renderer
+          spec={spec}
+          registry={registry}
+        />
+      )}
+    </div>
+  );
+}
+```
+
+### Example output
+
+The AI writes a brief explanation, then JSONL patches on their own lines:
+
+```
+Here's a dashboard showing the latest crypto prices:
+
+{"op":"add","path":"/root","value":"dashboard"}
+{"op":"add","path":"/state/prices","value":[{"name":"Bitcoin","price":98450},{"name":"Ethereum","price":3120}]}
+{"op":"add","path":"/elements/dashboard","value":{"type":"Grid","props":{"columns":"2"},"children":["btc","eth"]}}
+{"op":"add","path":"/elements/btc","value":{"type":"Metric","props":{"label":"Bitcoin","value":{"$state":"/prices/0/price"}}}}
+{"op":"add","path":"/elements/eth","value":{"type":"Metric","props":{"label":"Ethereum","value":{"$state":"/prices/1/price"}}}}
+```
+
+If the user asks a simple question ("what does BTC stand for?"), the AI replies with text only — no JSONL.
+
+## Quick Comparison
+
+<div className="my-6 overflow-x-auto">
+  <table className="mdx-table w-full text-sm border-collapse">
+    <thead>
+      <tr>
+        <th />
+        <th>Generate</th>
+        <th>Chat</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr>
+        <td>Output format</td>
+        <td>JSONL only</td>
+        <td>Text + JSONL</td>
+      </tr>
+      <tr>
+        <td>Text-only replies</td>
+        <td>No</td>
+        <td>Yes</td>
+      </tr>
+      <tr>
+        <td>System prompt</td>
+        <td><code>{"catalog.prompt()"}</code></td>
+        <td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
+      </tr>
+      <tr>
+        <td>Stream utility</td>
+        <td><code>{"useUIStream"}</code></td>
+        <td><code>{"pipeJsonRender"}</code>{" + "}<code>{"useJsonRenderMessage"}</code></td>
+      </tr>
+      <tr>
+        <td>Typical use case</td>
+        <td>Playground, builders</td>
+        <td>Chatbots, copilots</td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+
+Both modes use the same JSONL patch format (RFC 6902) and the same catalog/registry system. The only difference is whether the AI is allowed to include prose alongside the patches.
+
+## Next
+
+- Learn about the [JSONL streaming format](/docs/streaming)
+- See the [AI SDK integration](/docs/ai-sdk) for setup with the Vercel AI SDK

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

@@ -0,0 +1,412 @@
+export const metadata = { title: "Migration Guide" }
+
+# Migration Guide
+
+This guide covers breaking changes introduced in v0.5.0 and how to update your code.
+
+## State Provider
+
+`DataProvider` has been renamed to `StateProvider`, and its props have changed.
+
+**Before:**
+
+```tsx
+import { DataProvider } from "@json-render/react";
+
+<DataProvider data={myData} getValue={getter} setValue={setter}>
+  {children}
+</DataProvider>
+```
+
+**After:**
+
+```tsx
+import { StateProvider } from "@json-render/react";
+
+<StateProvider initialState={myData} onStateChange={(path, value) => console.log(path, value)}>
+  {children}
+</StateProvider>
+```
+
+`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
+
+| Before | After |
+|--------|-------|
+| `DataProvider` | `StateProvider` |
+| `data` prop | `initialState` prop |
+| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
+| `useData` | `useStateStore` |
+| `useDataValue` | `useStateValue` |
+| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
+| `DataModel` type | `StateModel` type |
+
+## Dynamic Expressions
+
+All dynamic value expressions have been renamed to use `$state`, `$item`, and `$index`.
+
+**Before:**
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "label": { "$path": "/user/name" },
+    "count": { "$data": "/items/length" }
+  }
+}
+```
+
+**After:**
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "label": { "$state": "/user/name" },
+    "count": { "$state": "/items/length" }
+  }
+}
+```
+
+Inside repeat scopes, use `$item` and `$index`:
+
+```json
+{
+  "type": "Card",
+  "props": {
+    "title": { "$item": "name" },
+    "subtitle": { "$index": true }
+  }
+}
+```
+
+| Before | After |
+|--------|-------|
+| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
+| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
+
+## Two-Way Binding
+
+Form components no longer use `valuePath` / `statePath` props. Instead, use `$bindState` expressions on the value prop, and `useBoundProp` in your registry.
+
+**Before (catalog):**
+
+```typescript
+Input: {
+  props: z.object({
+    label: z.string(),
+    valuePath: z.string(),
+    placeholder: z.string().optional(),
+  }),
+}
+```
+
+**Before (spec):**
+
+```json
+{
+  "type": "Input",
+  "props": { "label": "Email", "valuePath": "/form/email" }
+}
+```
+
+**Before (registry):**
+
+```tsx
+Input: ({ props }) => {
+  const [value, setValue] = useStateBinding(props.valuePath);
+  return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
+}
+```
+
+**After (catalog):**
+
+```typescript
+Input: {
+  props: z.object({
+    label: z.string(),
+    value: z.string().optional(),
+    placeholder: z.string().optional(),
+  }),
+}
+```
+
+**After (spec):**
+
+```json
+{
+  "type": "Input",
+  "props": { "label": "Email", "value": { "$bindState": "/form/email" } }
+}
+```
+
+**After (registry):**
+
+```tsx
+Input: ({ props, bindings }) => {
+  const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
+  return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
+}
+```
+
+`$bindState` reads from and writes to the given state path. Inside repeat scopes, use `$bindItem` to bind to a field on the current item:
+
+```json
+{
+  "type": "Checkbox",
+  "props": { "checked": { "$bindItem": "completed" } }
+}
+```
+
+## Visibility Conditions
+
+Visibility conditions have been renamed to use `$state`, `$and`, and `$or`.
+
+**Before:**
+
+```json
+{ "path": "/isAdmin" }
+{ "eq": [{ "path": "/role" }, "admin"] }
+{ "and": [{ "path": "/isAdmin" }, { "path": "/feature" }] }
+{ "or": [{ "path": "/roleA" }, { "path": "/roleB" }] }
+```
+
+**After:**
+
+```json
+{ "$state": "/isAdmin" }
+{ "$state": "/role", "eq": "admin" }
+{ "$and": [{ "$state": "/isAdmin" }, { "$state": "/feature" }] }
+{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
+```
+
+You can also use an array as shorthand for `$and`:
+
+```json
+[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
+```
+
+Inside repeat scopes, use `$item` and `$index`:
+
+```json
+{ "$item": "isActive" }
+{ "$index": true, "eq": 0 }
+```
+
+## Event System
+
+Components now use `emit` to fire named events. `onAction` has been removed.
+
+**Before:**
+
+```tsx
+Button: ({ props, onAction }) => (
+  <button onClick={() => onAction?.("press")}>{props.label}</button>
+)
+```
+
+**After:**
+
+```tsx
+Button: ({ props, emit }) => (
+  <button onClick={() => emit("press")}>{props.label}</button>
+)
+```
+
+`emit` is always defined (never `undefined`), so optional chaining is not needed.
+
+## Actions Context
+
+`dispatch` has been renamed to `execute`, and the provider prop has been renamed from `actionHandlers` to `handlers`.
+
+**Before:**
+
+```tsx
+const { dispatch } = useActions();
+dispatch({ action: "submit", params: {} });
+
+<ActionProvider actionHandlers={myHandlers}>
+```
+
+**After:**
+
+```tsx
+const { execute } = useActions();
+execute({ action: "submit", params: {} });
+
+<ActionProvider handlers={myHandlers}>
+```
+
+## Repeat / List Rendering
+
+The `repeat` field now uses `statePath` instead of `path`.
+
+**Before:**
+
+```json
+{
+  "type": "Column",
+  "repeat": { "path": "/todos", "key": "id" },
+  "children": ["todo-item"]
+}
+```
+
+**After:**
+
+```json
+{
+  "type": "Column",
+  "repeat": { "statePath": "/todos", "key": "id" },
+  "children": ["todo-item"]
+}
+```
+
+## Catalog Creation
+
+`createCatalog` and `generateSystemPrompt` have been replaced by `defineSchema` + `defineCatalog`.
+
+**Before:**
+
+```typescript
+import { createCatalog, generateSystemPrompt } from "@json-render/core";
+
+const catalog = createCatalog({
+  name: "my-app",
+  components: { /* ... */ },
+  actions: { /* ... */ },
+});
+
+const prompt = generateSystemPrompt(catalog);
+```
+
+**After:**
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+
+const catalog = defineCatalog(schema, {
+  components: { /* ... */ },
+  actions: { /* ... */ },
+});
+
+const prompt = catalog.prompt();
+
+// Chat mode prompt
+const chatPrompt = catalog.prompt({ mode: "chat" });
+```
+
+## Validation
+
+`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
+
+**Before:**
+
+```json
+{ "fn": "required", "message": "Required" }
+{ "fn": "minLength", "args": { "length": 8 }, "message": "Too short" }
+```
+
+**After:**
+
+```json
+{ "type": "required", "message": "Required" }
+{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
+```
+
+| Before | After |
+|--------|-------|
+| `{ fn: "required" }` | `{ type: "required" }` |
+| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
+| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
+
+## Visibility Provider
+
+The `auth` prop has been removed from `VisibilityProvider`. Auth state should be modeled as regular state.
+
+**Before:**
+
+```tsx
+<VisibilityProvider auth={{ isSignedIn: true, role: "admin" }}>
+```
+
+```json
+{ "auth": "signedIn" }
+```
+
+**After:**
+
+```tsx
+<StateProvider initialState={{ auth: { isSignedIn: true, role: "admin" } }}>
+  <VisibilityProvider>
+```
+
+```json
+{ "$state": "/auth/isSignedIn" }
+```
+
+## Codegen
+
+`traverseTree` has been renamed to `traverseSpec`, `SpecVisitor` to `TreeVisitor`, and the visitor callback now receives a `key` parameter.
+
+**Before:**
+
+```typescript
+import { traverseTree } from "@json-render/codegen";
+
+traverseTree(tree, (element) => {
+  // ...
+});
+```
+
+**After:**
+
+```typescript
+import { traverseSpec } from "@json-render/codegen";
+
+traverseSpec(spec, (element, key) => {
+  // ...
+});
+```
+
+## Action Params
+
+Action params in specs now use `statePath` instead of `path`.
+
+**Before:**
+
+```json
+{
+  "on": {
+    "press": { "action": "setState", "params": { "path": "/count", "value": 0 } }
+  }
+}
+```
+
+**After:**
+
+```json
+{
+  "on": {
+    "press": { "action": "setState", "params": { "statePath": "/count", "value": 0 } }
+  }
+}
+```
+
+## Removed Exports
+
+The following exports have been removed from `@json-render/core`:
+
+| Removed | Replacement |
+|---------|-------------|
+| `createCatalog` | `defineCatalog(schema, config)` |
+| `generateCatalogPrompt` | `catalog.prompt()` |
+| `generateSystemPrompt` | `catalog.prompt()` |
+| `ComponentDefinition` | Use catalog component config directly |
+| `CatalogConfig` | Use `defineCatalog` parameters |
+| `SystemPromptOptions` | Use `PromptOptions` |
+| `LogicExpression` | Use `VisibilityCondition` |
+| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
+| `evaluateLogicExpression` | Use `evaluateVisibility` |
+| `createRendererFromCatalog` | Use `defineRegistry` |
+| `traverseTree` (codegen) | Use `traverseSpec` |

+ 3 - 2
apps/web/app/(main)/docs/openapi/page.mdx

@@ -98,10 +98,11 @@ A typical OpenAPI schema for a request body:
 Create components that map to OpenAPI data types:
 
 ```typescript
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
-export const openapiCatalog = createCatalog({
+export const openapiCatalog = defineCatalog(schema, {
   components: {
     Form: {
       description: 'API form container',

+ 94 - 32
apps/web/app/(main)/docs/page.mdx

@@ -2,35 +2,97 @@ export const metadata = { title: "Introduction" }
 
 # Introduction
 
-The Generative UI framework. Generate dynamic, personalized UIs from prompts without sacrificing reliability.
-
-## What is json-render?
-
-json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails -- what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
-
-Every generated interface is safe and predictable.
-
-## Why json-render?
-
-### Guardrailed
-
-AI can only use components in your catalog. No arbitrary code generation. Predefined components and actions for safe, predictable output.
-
-### 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.
-
-### Cross-Platform
-
-Render on web with React and on mobile with React Native from the same catalog and spec format.
-
-## How it works
-
-1. Define the guardrails - what components, actions, and data bindings AI can use
-2. Prompt - describe what you want in natural language
-3. AI generates JSON - output is always predictable, constrained to your catalog
-4. Render fast - stream and render progressively as the model responds
+json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform.
+
+## What is Generative UI?
+
+Most AI integrations treat the interface as fixed. Developers build layouts ahead of time, and AI fills in the data — a chatbot response, a summary, a recommendation. The UI itself never changes.
+
+**Generative UI is different.** The AI generates the interface itself: which components to show, how to arrange them, what data to bind, what actions to wire up. Every response can produce a unique, purpose-built UI tailored to the user's request.
+
+The challenge is that unconstrained AI output is unpredictable. It can hallucinate component names, produce invalid structures, or generate unsafe code. You need a way to let AI be creative with layout and composition while keeping it within boundaries you control.
+
+That is what json-render does. You define a **catalog** of components and actions. AI generates JSON constrained to that catalog. Your components render the result natively — on web or mobile — with full type safety and no arbitrary code execution.
+
+## How json-render Works
+
+### 1. Define your catalog
+
+A catalog declares what AI can use: components with typed props, actions with typed params.
+
+```typescript
+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() }),
+      slots: ["default"],
+    },
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+      }),
+    },
+  },
+});
+```
+
+### 2. AI generates a spec
+
+Given a prompt like "show me a revenue dashboard", AI outputs a JSON spec — a flat tree of elements constrained to your catalog:
+
+```json
+{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "type": "Card",
+      "props": { "title": "Revenue Dashboard" },
+      "children": ["metric-1", "metric-2"]
+    },
+    "metric-1": {
+      "type": "Metric",
+      "props": { "label": "Total Revenue", "value": "$48,200" }
+    },
+    "metric-2": {
+      "type": "Metric",
+      "props": { "label": "Growth", "value": "+12%" }
+    }
+  }
+}
+```
+
+### 3. Your components render it
+
+Map catalog types to real components with a registry, then render the spec:
+
+```tsx
+import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
+
+<StateProvider initialState={{}}>
+  <VisibilityProvider>
+    <Renderer spec={spec} registry={registry} />
+  </VisibilityProvider>
+</StateProvider>
+```
+
+The result is a native UI built from your own components — not an iframe, not markdown, not generated code. The AI chose the structure; you control everything else.
+
+## Key Concepts
+
+- **[Catalog](/docs/catalog)** — Define the components, actions, and validation functions AI can use. This is the contract between your app and the AI.
+- **[Registry](/docs/registry)** — Map catalog types to platform-specific implementations. React components on web, React Native views on mobile.
+- **[Specs](/docs/specs)** — The JSON output AI generates. A flat tree of typed elements with props, children, data bindings, and visibility conditions.
+- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
+- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
+- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
+- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
+
+## Next
+
+- [Installation](/docs/installation) — Add json-render to your project
+- [Quick Start](/docs/quick-start) — Build your first generative UI in 5 minutes

+ 18 - 16
apps/web/app/(main)/docs/quick-start/page.mdx

@@ -74,7 +74,7 @@ export const { registry } = defineRegistry(catalog, {
     Button: ({ props, emit }) => (
       <button
         className="px-4 py-2 bg-blue-500 text-white rounded"
-        onClick={() => emit?.("press")}
+        onClick={() => emit("press")}
       >
         {props.label}
       </button>
@@ -119,7 +119,7 @@ Use providers and the `Renderer` with your registry to display AI-generated UI:
 // app/page.tsx
 'use client';
 
-import { Renderer, StateProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
+import { Renderer, StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, useUIStream } from '@json-render/react';
 import { registry } from '@/lib/registry';
 
 export default function Page() {
@@ -140,20 +140,22 @@ export default function Page() {
           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>
+          <ValidationProvider customFunctions={{}}>
+            <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>
+          </ValidationProvider>
         </ActionProvider>
       </VisibilityProvider>
     </StateProvider>

+ 47 - 33
apps/web/app/(main)/docs/registry/page.mdx

@@ -30,8 +30,8 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
       </div>
     ),
 
-    Button: ({ props, onAction }) => (
-      <button onClick={() => onAction?.({ name: props.action })}>
+    Button: ({ props, emit }) => (
+      <button onClick={() => emit("press")}>
         {props.label}
       </button>
     ),
@@ -67,15 +67,37 @@ Each component 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: T;                                // Type-safe props from your catalog
+  children?: React.ReactNode;              // Rendered children (for slot components)
+  emit: (event: string) => void;           // Emit a named event (e.g. "press")
+  loading?: boolean;                       // Whether the renderer is in a loading state
+  bindings?: Record<string, string>;       // State paths from $bindState/$bindItem expressions
 }
 ```
 
 Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
 
+#### Using `bindings` for two-way binding
+
+When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
+
+```tsx
+import { useBoundProp, defineRegistry } from '@json-render/react';
+
+// Inside your registry:
+TextInput: ({ props, bindings }) => {
+  const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
+  return (
+    <input
+      value={value ?? ""}
+      onChange={(e) => setValue(e.target.value)}
+    />
+  );
+},
+```
+
+`useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.
+
 ### Action Handlers
 
 Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
@@ -110,7 +132,7 @@ const catalog = defineCatalog(schema, {
 });
 ```
 
-Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
+Action handlers receive `(params, setState, state)` and are defined inside `defineRegistry`:
 
 ```tsx
 export const { handlers, executeAction } = defineRegistry(catalog, {
@@ -138,40 +160,32 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
 
 ### Data Binding
 
-Use hooks inside your registry components to read and write data:
+Most data binding is handled automatically by the renderer — `$state`, `$item`, and `$index` expressions in props are resolved before your component receives them. See the [Data Binding](/docs/data-binding) guide for the full reference.
+
+For two-way binding (form inputs), use `{ "$bindState": "/path" }` on the natural value prop (or `{ "$bindItem": "field" }` inside repeat scopes). The renderer provides a `bindings` map with the state path for each bound prop. Use `useBoundProp` to get `[value, setValue]`:
 
 ```tsx
-import { useStateStore } from '@json-render/react';
-import { getByPath } from '@json-render/core';
+import { useBoundProp } from '@json-render/react';
 
 // 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>
+Input: ({ props, bindings }) => {
+  const [value, setValue] = useBoundProp<string>(
+    props.value,
+    bindings?.value
   );
-},
-
-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)}
+      value={value ?? ''}
+      onChange={(e) => setValue(e.target.value)}
       placeholder={props.placeholder}
     />
   );
 },
 ```
 
+For read-only state access (e.g. displaying a value from state), use `$state` expressions in props — they are resolved before the component receives them. For custom logic, use `useStateStore` and `getByPath` from `@json-render/core`.
+
 ### Using the Renderer
 
 Wire everything together with providers and the `<Renderer />` component:
@@ -186,19 +200,19 @@ import {
 } from '@json-render/react';
 import { registry, handlers } from './registry';
 
-function App({ spec, data, setState }) {
-  const dataRef = useRef(data);
+function App({ spec, state, setState }) {
+  const stateRef = useRef(state);
   const setStateRef = useRef(setState);
-  dataRef.current = data;
+  stateRef.current = state;
   setStateRef.current = setState;
 
   const actionHandlers = useMemo(
-    () => handlers(() => setStateRef.current, () => dataRef.current),
+    () => handlers(() => setStateRef.current, () => stateRef.current),
     [],
   );
 
   return (
-    <StateProvider initialState={data}>
+    <StateProvider initialState={state}>
       <VisibilityProvider>
         <ActionProvider handlers={actionHandlers}>
           <Renderer spec={spec} registry={registry} />
@@ -227,7 +241,7 @@ export const { registry } = defineRegistry(catalog, {
     ),
 
     Button: ({ props, emit }) => (
-      <Pressable onPress={() => emit?.("press")}>
+      <Pressable onPress={() => emit("press")}>
         <Text>{props.label}</Text>
       </Pressable>
     ),

+ 8 - 6
apps/web/app/(main)/docs/schemas/page.mdx

@@ -41,7 +41,7 @@ See the [Custom Schema guide](/docs/custom-schema) to learn how to implement sup
     },
     "text-1": {
       "type": "Text",
-      "props": { "content": "Welcome, $data.user.name" },
+      "props": { "content": { "$state": "/user/name" } },
       "children": []
     },
     "button-1": {
@@ -64,25 +64,27 @@ 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
+  visible?: VisibilityCondition;  // Conditional display
 }
 ```
 
 ### Data Binding Syntax
 
-Reference dynamic data using the `$data` prefix in props:
+Reference dynamic data using `$state` expressions in props. The value is a JSON Pointer path into the state model:
 
 ```json
 {
   "type": "Text",
   "props": {
-    "content": "$data.user.name",
-    "count": "$data.items.length"
+    "content": { "$state": "/user/name" },
+    "count": { "$state": "/items/count" }
   },
   "children": []
 }
 ```
 
+json-render also supports `$item` and `$index` expressions for lists, two-way binding via `$bindState` / `$bindItem`, and conditional props. See [Data Binding](/docs/data-binding) for the full reference.
+
 ### Action Format
 
 Actions are defined in the catalog and referenced from components. The renderer handles action execution:
@@ -121,7 +123,7 @@ const MyElementSchema = z.object({
 // Define your own data binding format
 const BoundValue = z.object({
   literal: z.string().optional(),
-  path: z.string().optional(),  // e.g., "/users/0/name"
+  source: z.string().optional(),  // e.g., "/users/0/name"
 });
 
 // Define your own action format

+ 43 - 31
apps/web/app/(main)/docs/specs/page.mdx

@@ -6,7 +6,7 @@ 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:
+A spec (specification) is the actual JSON that describes a UI. It uses components from a [catalog](/docs/catalog) and can optionally follow a [schema](/docs/schemas). Specs can be:
 
 - Generated by AI in real-time
 - Stored in a database
@@ -32,7 +32,7 @@ A basic spec using the `@json-render/react` schema. Note the flat structure with
     },
     "text-1": {
       "type": "Text",
-      "props": { "content": "Hello, $data.user.name!" },
+      "props": { "content": { "$state": "/user/greeting" } },
       "children": []
     }
   }
@@ -59,7 +59,7 @@ A more complex spec with multiple nested elements:
     },
     "avatar-1": {
       "type": "Avatar",
-      "props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
+      "props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } },
       "children": []
     },
     "stack-1": {
@@ -69,12 +69,12 @@ A more complex spec with multiple nested elements:
     },
     "name-text": {
       "type": "Text",
-      "props": { "content": "$data.user.name", "variant": "heading" },
+      "props": { "content": { "$state": "/user/name" }, "variant": "heading" },
       "children": []
     },
     "email-text": {
       "type": "Text",
-      "props": { "content": "$data.user.email", "variant": "caption" },
+      "props": { "content": { "$state": "/user/email" }, "variant": "caption" },
       "children": []
     },
     "button-1": {
@@ -145,9 +145,11 @@ A high-level spec using semantic blocks for page layouts:
 
 ## Spec Anatomy
 
+Specs are schema-agnostic — the JSON structure is entirely up to you. The examples below use the `root` + `elements` flat tree format from the `@json-render/react` schema, which is optimized for AI generation and streaming.
+
 ### Root and Elements
 
-Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
+In the React schema, a spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
 
 ```json
 {
@@ -181,20 +183,22 @@ Each element in the map has a consistent shape:
 
 ### Dynamic Data
 
-Props can reference data using `$data` paths:
+Props can reference data from the state model using `$state` expressions. The value is a JSON Pointer (RFC 6901) path into the state:
 
 ```json
 {
   "type": "Metric",
   "props": {
     "label": "Total Revenue",
-    "value": "$data.metrics.revenue",
-    "change": "$data.metrics.revenueChange"
+    "value": { "$state": "/metrics/revenue" },
+    "change": { "$state": "/metrics/revenueChange" }
   },
   "children": []
 }
 ```
 
+See [Data Binding](/docs/data-binding) for the full reference including `$item`, `$index`, repeat, and two-way binding.
+
 ### Conditional Visibility
 
 Control when elements appear using the `visible` property:
@@ -207,46 +211,52 @@ Control when elements appear using the `visible` property:
   },
   "children": [],
   "visible": {
-    "path": "$data.form.isDirty",
-    "operator": "eq",
-    "value": true
+    "$state": "/form/isDirty",
+    "eq": true
   }
 }
 ```
 
 ## Working with Specs
 
-### Rendering a Spec
+### Validating a Spec
 
-```tsx
-import { Renderer } from '@json-render/react';
+Use `validateSpec` from `@json-render/core` to check a spec for structural issues:
 
-function MyApp({ spec, data }) {
-  return (
-    <Renderer
-      spec={spec}
-      data={data}
-      registry={registry}
-    />
-  );
+```typescript
+import { validateSpec } from '@json-render/core';
+
+const result = validateSpec(spec);
+
+if (!result.valid) {
+  console.error('Invalid spec:', result.issues);
 }
 ```
 
-### Validating a Spec
+### Rendering a Spec (React)
 
-```typescript
-import { validate } from '@json-render/core';
+With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility:
 
-const result = validate(spec, catalog);
+```tsx
+import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
+import { registry } from './registry';
 
-if (!result.valid) {
-  console.error('Invalid spec:', result.errors);
+function MyApp({ spec, initialState }) {
+  return (
+    <StateProvider initialState={initialState}>
+      <VisibilityProvider>
+        <Renderer spec={spec} registry={registry} />
+      </VisibilityProvider>
+    </StateProvider>
+  );
 }
 ```
 
-### Streaming Specs
+See the [@json-render/react API reference](/docs/api/react) for full provider and hook documentation.
 
-Specs can be streamed incrementally for progressive rendering:
+### Streaming a Spec (React)
+
+With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally:
 
 ```tsx
 import { useUIStream } from '@json-render/react';
@@ -266,6 +276,8 @@ function GenerativeUI() {
 }
 ```
 
+See [Streaming](/docs/streaming) for the full SpecStream format and server-side setup.
+
 ## Spec Sources
 
 Specs can come from various sources:

+ 78 - 71
apps/web/app/(main)/docs/streaming/page.mdx

@@ -15,28 +15,6 @@ json-render uses **SpecStream**, a JSONL-based streaming format where each line
 {"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:
@@ -50,13 +28,13 @@ SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6
 
 ## Path Format
 
-Paths use a key-based format for elements:
+Paths follow JSON Pointer (RFC 6901) into the spec object:
 
 ```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
+/root                       -> Root element key (string)
+/elements/card-1            -> Element with key "card-1"
+/elements/card-1/props      -> Props of card-1
+/elements/card-1/children   -> Children of card-1
 ```
 
 ## Server-Side Setup
@@ -81,7 +59,77 @@ export async function POST(req: Request) {
 }
 ```
 
-## Progressive Rendering
+## Low-Level SpecStream API
+
+For custom or framework-agnostic streaming implementations, use the SpecStream compiler from `@json-render/core` directly:
+
+```typescript
+import { createSpecStreamCompiler } from '@json-render/core';
+
+// Create a compiler for your spec type
+const compiler = createSpecStreamCompiler<MySpec>();
+const decoder = new TextDecoder();
+
+// Process streaming chunks from AI
+async function processStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+
+    // Decode the Uint8Array chunk to a string
+    const chunk = decoder.decode(value, { stream: true });
+    const { result, newPatches } = compiler.push(chunk);
+    
+    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":"card-1"}
+{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}`;
+
+const spec = compileSpecStream<Spec>(jsonl);
+// { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: [] } } }
+```
+
+## Usage with React
+
+`@json-render/react` provides the `useUIStream` hook, which wraps the low-level compiler in a React-friendly API with state management, error handling, and abort support.
+
+### useUIStream Hook
+
+```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
+  });
+}
+```
+
+### Progressive Rendering
 
 The Renderer automatically updates as the spec changes:
 
@@ -98,7 +146,7 @@ function App() {
 }
 ```
 
-## Aborting Streams
+### Aborting Streams
 
 Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
 
@@ -119,45 +167,4 @@ function App() {
 }
 ```
 
-## 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" } } }
-```
+See the [@json-render/react API reference](/docs/api/react) for full `useUIStream` documentation.

+ 28 - 19
apps/web/app/(main)/docs/validation/page.mdx

@@ -18,15 +18,17 @@ json-render includes common validation functions:
 
 ## Using Validation in JSON
 
+Use `{ "$bindState": "/path" }` on the value prop for two-way binding. Validation checks run against the value at the bound path (available as `bindings?.value` in components):
+
 ```json
 {
   "type": "TextField",
   "props": {
     "label": "Email",
-    "valuePath": "/form/email",
+    "value": { "$bindState": "/form/email" },
     "checks": [
-      { "fn": "required", "message": "Email is required" },
-      { "fn": "email", "message": "Invalid email format" }
+      { "type": "required", "message": "Email is required" },
+      { "type": "email", "message": "Invalid email format" }
     ],
     "validateOn": "blur"
   }
@@ -40,16 +42,16 @@ json-render includes common validation functions:
   "type": "TextField",
   "props": {
     "label": "Password",
-    "valuePath": "/form/password",
+    "value": { "$bindState": "/form/password" },
     "checks": [
-      { "fn": "required", "message": "Password is required" },
+      { "type": "required", "message": "Password is required" },
       { 
-        "fn": "minLength", 
-        "args": { "length": 8 },
+        "type": "minLength", 
+        "args": { "min": 8 },
         "message": "Password must be at least 8 characters"
       },
       {
-        "fn": "pattern",
+        "type": "pattern",
         "args": { "pattern": "[A-Z]" },
         "message": "Must contain at least one uppercase letter"
       }
@@ -60,11 +62,11 @@ json-render includes common validation functions:
 
 ## Custom Validation Functions
 
-Define custom validators in your catalog:
+Define custom validators in your catalog's `functions` field. The catalog itself is framework-agnostic — only the `schema` import varies by platform:
 
 ```typescript
 import { defineCatalog } from '@json-render/core';
-import { schema } from '@json-render/react';
+import { schema } from '@json-render/react'; // or '@json-render/react-native'
 import { z } from 'zod';
 
 const catalog = defineCatalog(schema, {
@@ -80,7 +82,9 @@ const catalog = defineCatalog(schema, {
 });
 ```
 
-Then implement them in your ValidationProvider:
+## Usage with React
+
+In `@json-render/react`, use `ValidationProvider` to supply implementations for your custom validators:
 
 ```tsx
 import { ValidationProvider } from '@json-render/react';
@@ -99,22 +103,25 @@ function App() {
   };
 
   return (
-    <ValidationProvider functions={customValidators}>
+    <ValidationProvider customFunctions={customValidators}>
       {/* Your UI */}
     </ValidationProvider>
   );
 }
 ```
 
-## Using in Components
+### Using in Components
+
+The `useFieldValidation` and `useBoundProp` hooks wire validation into your registry components. Validation uses the path from `bindings?.value` (the bound state path):
 
 ```tsx
-import { useFieldValidation } from '@json-render/react';
+import { useFieldValidation, useBoundProp } from '@json-render/react';
 
-function TextField({ props }) {
-  const { value, setValue, errors, validate } = useFieldValidation(
-    props.valuePath,
-    props.checks
+function TextField({ props, bindings }) {
+  const [value, setValue] = useBoundProp(props.value, bindings?.value);
+  const { errors, isValid, validate, touch, clear } = useFieldValidation(
+    bindings?.value ?? null,
+    { checks: props.checks, validateOn: props.validateOn }
   );
 
   return (
@@ -133,6 +140,8 @@ function TextField({ props }) {
 }
 ```
 
+See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
+
 ## Validation Timing
 
 Control when validation runs with `validateOn`:
@@ -143,4 +152,4 @@ Control when validation runs with `validateOn`:
 
 ## Next
 
-Learn about [AI SDK integration](/docs/ai-sdk).
+Learn about [generation modes](/docs/generation-modes).

+ 264 - 67
apps/web/app/(main)/docs/visibility/page.mdx

@@ -2,140 +2,337 @@ export const metadata = { title: "Visibility" }
 
 # Visibility
 
-Conditionally show or hide components based on data, auth, or logic.
+Conditionally show or hide components based on state values and logic.
 
-## VisibilityProvider
+## State-Based Visibility
 
-Wrap your app with VisibilityProvider to enable conditional rendering:
+Show/hide based on state values. Use `$state` with a JSON Pointer path:
 
-```tsx
-import { VisibilityProvider } from '@json-render/react';
-
-function App() {
-  return (
-    <StateProvider initialState={data}>
-      <VisibilityProvider>
-        {/* Components can now use visibility conditions */}
-      </VisibilityProvider>
-    </StateProvider>
-  );
+```json
+{
+  "type": "Alert",
+  "props": { "message": "Form has errors" },
+  "visible": { "$state": "/form/hasErrors" }
 }
 ```
 
-## Path-Based Visibility
+Visible when `/form/hasErrors` is truthy.
+
+### Negation
 
-Show/hide based on data values:
+Use `not: true` to invert a condition:
 
 ```json
 {
-  "type": "Alert",
-  "props": { "message": "Form has errors" },
-  "visible": { "path": "/form/hasErrors" }
+  "type": "WelcomeBanner",
+  "visible": { "$state": "/user/hasSeenWelcome", "not": true }
 }
-
-// Visible when /form/hasErrors is truthy
 ```
 
+Visible when `/user/hasSeenWelcome` is falsy.
+
 ## Auth-Based Visibility
 
-Show/hide based on authentication state:
+Show/hide based on authentication state. Expose your auth state in the state model (e.g. at `/auth/isSignedIn`):
 
 ```json
 {
   "type": "AdminPanel",
-  "visible": { "auth": "signedIn" }
+  "visible": { "$state": "/auth/isSignedIn" }
 }
+```
 
-// Options: "signedIn", "signedOut", "admin", etc.
+For signed-out only:
+
+```json
+{
+  "type": "LoginPrompt",
+  "visible": { "$state": "/auth/isSignedIn", "not": true }
+}
 ```
 
-## Logic Expressions
+## Comparison Operators
 
-Combine conditions with logic operators:
+Compare a state value to a literal or another state path. Use **one operator per condition** -- if multiple are provided, only the first one is evaluated (precedence: `eq` > `neq` > `gt` > `gte` > `lt` > `lte`). Add `"not": true` to invert the result of any condition.
+
+```json
+// Equal
+{
+  "visible": { "$state": "/user/role", "eq": "admin" }
+}
+
+// Not equal
+{
+  "visible": { "$state": "/tab", "neq": "home" }
+}
+
+// Greater than
+{
+  "visible": { "$state": "/cart/total", "gt": 100 }
+}
+
+// Greater than or equal
+{
+  "visible": { "$state": "/cart/itemCount", "gte": 1 }
+}
+
+// Less than
+{
+  "visible": { "$state": "/cart/total", "lt": 1000 }
+}
+
+// Less than or equal
+{
+  "visible": { "$state": "/cart/itemCount", "lte": 10 }
+}
+```
+
+Comparison values can be literals or state references:
+
+```json
+{
+  "visible": { "$state": "/user/balance", "gte": { "$state": "/order/minimum" } }
+}
+```
+
+## Combining Conditions (AND)
+
+Place multiple conditions in an array for implicit AND:
 
 ```json
-// AND - all conditions must be true
 {
   "type": "SubmitButton",
-  "visible": {
-    "and": [
-      { "path": "/form/isValid" },
-      { "path": "/form/hasChanges" }
-    ]
-  }
+  "visible": [
+    { "$state": "/form/isValid" },
+    { "$state": "/form/hasChanges" }
+  ]
+}
+```
+
+All conditions must be true for the element to be visible.
+
+## OR Conditions
+
+Use `$or` when at least one condition should be true:
+
+```json
+{
+  "type": "SpecialOffer",
+  "visible": { "$or": [
+    { "$state": "/user/isVIP" },
+    { "$state": "/cart/total", "gt": 200 }
+  ]}
 }
+```
+
+Visible when the user is VIP **or** the cart total exceeds 200. `$or` can contain any visibility conditions, including nested arrays (AND) and comparisons.
+
+## Explicit AND
+
+Use `$and` when you need to nest AND logic inside `$or`:
+
+```json
+{
+  "type": "PromoCard",
+  "visible": { "$or": [
+    { "$and": [
+      { "$state": "/user/isVIP" },
+      { "$state": "/cart/total", "gt": 50 }
+    ]},
+    { "$state": "/promo/active" }
+  ]}
+}
+```
+
+For top-level AND, the implicit array form is simpler: `[condition, condition]`. Use `$and` only when nesting inside `$or`.
 
-// OR - any condition must be true
+## Always / Never
+
+Use boolean literals for constant visibility:
+
+```json
 {
-  "type": "HelpText",
-  "visible": {
-    "or": [
-      { "path": "/user/isNew" },
-      { "path": "/settings/showHelp" }
-    ]
-  }
+  "type": "Footer",
+  "visible": true
 }
+```
 
-// NOT - invert a condition
+```json
 {
-  "type": "WelcomeBanner",
-  "visible": {
-    "not": { "path": "/user/hasSeenWelcome" }
-  }
+  "type": "DeprecatedPanel",
+  "visible": false
 }
 ```
 
-## Comparison Operators
+## Repeat-Scoped Conditions
+
+Inside a [repeat](/docs/data-binding#repeat), use `$item` and `$index` conditions to show/hide based on the current item:
+
+### `$item` — Condition on item field
 
 ```json
-// Equal
 {
-  "visible": {
-    "eq": [{ "path": "/user/role" }, "admin"]
-  }
+  "type": "Badge",
+  "props": { "label": "Overdue" },
+  "visible": { "$item": "isOverdue" }
 }
+```
 
-// Greater than
+With comparison:
+
+```json
 {
-  "visible": {
-    "gt": [{ "path": "/cart/total" }, 100]
-  }
+  "type": "DiscountTag",
+  "visible": { "$item": "price", "gt": 100 }
 }
+```
 
-// Available: eq, ne, gt, gte, lt, lte
+### `$index` — Condition on array index
+
+```json
+{
+  "type": "Divider",
+  "visible": { "$index": true, "gt": 0 }
+}
 ```
 
+This shows the divider for every item except the first (index 0).
+
+`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
+
 ## 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" } }
-    ]
-  }
+  "visible": [
+    { "$state": "/auth/isSignedIn" },
+    { "$state": "/user/role", "eq": "support" },
+    { "$state": "/order/amount", "gt": 0 },
+    { "$state": "/order/isRefunded", "not": true }
+  ]
 }
 ```
 
-## Using in Components
+## Quick Reference
+
+<div className="my-6 overflow-x-auto">
+  <table className="mdx-table w-full text-sm border-collapse">
+    <thead>
+      <tr>
+        <th>Condition</th>
+        <th>Syntax</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr>
+        <td>Truthiness</td>
+        <td><code>{'{ "$state": "/path" }'}</code></td>
+      </tr>
+      <tr>
+        <td>Falsy (not)</td>
+        <td><code>{'{ "$state": "/path", "not": true }'}</code></td>
+      </tr>
+      <tr>
+        <td>Equal</td>
+        <td><code>{'{ "$state": "/path", "eq": value }'}</code></td>
+      </tr>
+      <tr>
+        <td>Not equal</td>
+        <td><code>{'{ "$state": "/path", "neq": value }'}</code></td>
+      </tr>
+      <tr>
+        <td>Greater than</td>
+        <td><code>{'{ "$state": "/path", "gt": number }'}</code></td>
+      </tr>
+      <tr>
+        <td>Greater or equal</td>
+        <td><code>{'{ "$state": "/path", "gte": number }'}</code></td>
+      </tr>
+      <tr>
+        <td>Less than</td>
+        <td><code>{'{ "$state": "/path", "lt": number }'}</code></td>
+      </tr>
+      <tr>
+        <td>Less or equal</td>
+        <td><code>{'{ "$state": "/path", "lte": number }'}</code></td>
+      </tr>
+      <tr>
+        <td>Item field (repeat)</td>
+        <td><code>{'{ "$item": "field" }'}</code></td>
+      </tr>
+      <tr>
+        <td>Item comparison</td>
+        <td><code>{'{ "$item": "field", "eq": value }'}</code></td>
+      </tr>
+      <tr>
+        <td>Index (repeat)</td>
+        <td><code>{'{ "$index": true, "gt": 0 }'}</code></td>
+      </tr>
+      <tr>
+        <td>AND (implicit)</td>
+        <td><code>{"[ condition, condition ]"}</code></td>
+      </tr>
+      <tr>
+        <td>AND (explicit)</td>
+        <td><code>{'{ "$and": [ condition, condition ] }'}</code></td>
+      </tr>
+      <tr>
+        <td>OR</td>
+        <td><code>{'{ "$or": [ condition, condition ] }'}</code></td>
+      </tr>
+      <tr>
+        <td>Always</td>
+        <td><code>{"true"}</code></td>
+      </tr>
+      <tr>
+        <td>Never</td>
+        <td><code>{"false"}</code></td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+
+Comparison values can be literals or state references for state-to-state comparisons:
+
+```json
+{ "$state": "/a", "eq": { "$state": "/b" } }
+```
+
+## Usage with React
+
+In `@json-render/react`, wrap your app with `VisibilityProvider` to enable conditional rendering. The `Renderer` handles visibility automatically — elements with unmet conditions are not rendered.
+
+```tsx
+import { VisibilityProvider, StateProvider } from '@json-render/react';
+
+function App() {
+  return (
+    <StateProvider initialState={data}>
+      <VisibilityProvider>
+        {/* Components can now use visibility conditions */}
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+```
+
+For advanced use cases, the `useIsVisible` hook lets you evaluate visibility conditions programmatically:
 
 ```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>;
 }
 ```
 
+See the [@json-render/react API reference](/docs/api/react) for full details.
+
 ## Next
 
 Learn about [form validation](/docs/validation).

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

@@ -100,10 +100,12 @@ export default function Home() {
               <p className="text-muted-foreground mb-6">
                 Components, actions, and validation functions.
               </p>
-              <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+              <Code lang="typescript">{`import { defineSchema, defineCatalog } from '@json-render/core';
 import { z } from 'zod';
 
-export const catalog = createCatalog({
+const schema = defineSchema({ /* ... */ });
+
+export const catalog = defineCatalog(schema, {
   components: {
     Card: {
       props: z.object({
@@ -115,7 +117,7 @@ export const catalog = createCatalog({
     Metric: {
       props: z.object({
         label: z.string(),
-        valuePath: z.string(),
+        statePath: z.string(),
         format: z.enum(['currency', 'percent']),
       }),
     },
@@ -144,7 +146,7 @@ export const catalog = createCatalog({
       "type": "Metric",
       "props": {
         "label": "Total Revenue",
-        "valuePath": "/metrics/revenue",
+        "statePath": "/metrics/revenue",
         "format": "currency"
       }
     }
@@ -183,7 +185,7 @@ export const catalog = createCatalog({
       "type": "Metric",
       "props": {
         "label": "Total Revenue",
-        "valuePath": "analytics/revenue",
+        "statePath": "analytics/revenue",
         "format": "currency"
       }
     },
@@ -223,7 +225,7 @@ export default function Page() {
       <Metric
         data={data}
         label="Total Revenue"
-        valuePath="analytics/revenue"
+        statePath="analytics/revenue"
         format="currency"
       />
       <Chart data={data} statePath="analytics/salesByRegion" />
@@ -266,7 +268,7 @@ export default function Page() {
               },
               {
                 title: "Data Binding",
-                desc: "Two-way state binding with dynamic prop expressions",
+                desc: "Connect props to state with $state, $item, $index, and two-way binding",
               },
               {
                 title: "Code Export",

+ 2 - 1
apps/web/app/api/generate/route.ts

@@ -14,6 +14,7 @@ const SYSTEM_PROMPT = playgroundCatalog.prompt({
     "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.",
+    'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
   ],
 });
 
@@ -82,7 +83,7 @@ export async function POST(req: Request) {
         });
         controller.enqueue(encoder.encode(`\n${meta}\n`));
       } catch {
-        // Usage not available -- skip silently
+        // Usage not available  skip silently
       }
       controller.close();
     },

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

@@ -154,6 +154,23 @@ button {
   margin-bottom: 0.5em;
 }
 
+/* MDX table styles — fallback for GFM-generated tables */
+.mdx-table th,
+.mdx-table td {
+  border: 1px solid var(--border);
+  padding: 0.75rem 1rem;
+  text-align: left;
+}
+
+.mdx-table th {
+  font-weight: 600;
+  background-color: var(--muted);
+}
+
+.mdx-table td {
+  color: var(--muted-foreground);
+}
+
 /* Shiki dual theme support */
 .shiki,
 .shiki span {

+ 67 - 125
apps/web/components/demo.tsx

@@ -16,6 +16,7 @@ import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
 import { PlaygroundRenderer } from "@/lib/render/renderer";
 import { playgroundCatalog } from "@/lib/render/catalog";
+import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
 
 const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
 
@@ -24,10 +25,54 @@ interface SimulationStage {
   stream: string;
 }
 
+// Shared state & element definitions for the progressive simulation stages.
+const FORM_STATE = { form: { name: "", email: "", message: "" } };
+
+const NAME_INPUT = {
+  type: "Input",
+  props: {
+    label: "Name",
+    name: "name",
+    statePath: "/form/name",
+    checks: [{ type: "required", message: "Name is required" }],
+  },
+} as const;
+
+const EMAIL_INPUT = {
+  type: "Input",
+  props: {
+    label: "Email",
+    name: "email",
+    type: "email",
+    statePath: "/form/email",
+    checks: [
+      { type: "required", message: "Email is required" },
+      { type: "email", message: "Please enter a valid email" },
+    ],
+  },
+} as const;
+
+const MESSAGE_INPUT = {
+  type: "Textarea",
+  props: {
+    label: "Message",
+    name: "message",
+    statePath: "/form/message",
+    checks: [{ type: "required", message: "Message is required" }],
+  },
+} as const;
+
+const SUBMIT_BUTTON = {
+  type: "Button",
+  props: { label: "Send Message", variant: "primary" },
+  on: { press: { action: "formSubmit" } },
+} as const;
+
 const SIMULATION_STAGES: SimulationStage[] = [
   {
     tree: {
       root: "card",
+      state: FORM_STATE,
       elements: {
         card: {
           type: "Card",
@@ -41,98 +86,72 @@ const SIMULATION_STAGES: SimulationStage[] = [
   {
     tree: {
       root: "card",
+      state: FORM_STATE,
       elements: {
         card: {
           type: "Card",
           props: { title: "Contact Us", maxWidth: "md" },
           children: ["name"],
         },
-        name: {
-          type: "Input",
-          props: { label: "Name", name: "name" },
-        },
+        name: NAME_INPUT,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
+      '{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
   },
   {
     tree: {
       root: "card",
+      state: FORM_STATE,
       elements: {
         card: {
           type: "Card",
           props: { title: "Contact Us", maxWidth: "md" },
           children: ["name", "email"],
         },
-        name: {
-          type: "Input",
-          props: { label: "Name", name: "name" },
-        },
-        email: {
-          type: "Input",
-          props: { label: "Email", name: "email" },
-        },
+        name: NAME_INPUT,
+        email: EMAIL_INPUT,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
+      '{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
   },
   {
     tree: {
       root: "card",
+      state: FORM_STATE,
       elements: {
         card: {
           type: "Card",
           props: { title: "Contact Us", maxWidth: "md" },
           children: ["name", "email", "message"],
         },
-        name: {
-          type: "Input",
-          props: { label: "Name", name: "name" },
-        },
-        email: {
-          type: "Input",
-          props: { label: "Email", name: "email" },
-        },
-        message: {
-          type: "Textarea",
-          props: { label: "Message", name: "message" },
-        },
+        name: NAME_INPUT,
+        email: EMAIL_INPUT,
+        message: MESSAGE_INPUT,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
+      '{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
   },
   {
     tree: {
       root: "card",
+      state: FORM_STATE,
       elements: {
         card: {
           type: "Card",
           props: { title: "Contact Us", maxWidth: "md" },
           children: ["name", "email", "message", "submit"],
         },
-        name: {
-          type: "Input",
-          props: { label: "Name", name: "name" },
-        },
-        email: {
-          type: "Input",
-          props: { label: "Email", name: "email" },
-        },
-        message: {
-          type: "Textarea",
-          props: { label: "Message", name: "message" },
-        },
-        submit: {
-          type: "Button",
-          props: { label: "Send Message", variant: "primary" },
-        },
+        name: NAME_INPUT,
+        email: EMAIL_INPUT,
+        message: MESSAGE_INPUT,
+        submit: SUBMIT_BUTTON,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
+      '{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
   },
 ];
 
@@ -230,87 +249,10 @@ export function Demo({
   >("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 };
-  }, []);
+  const catalogData = useMemo(
+    () => buildCatalogDisplayData(playgroundCatalog.data),
+    [],
+  );
 
   // Disable body scroll when any modal is open
   useEffect(() => {

+ 115 - 0
apps/web/components/generation-modes-diagram.tsx

@@ -0,0 +1,115 @@
+"use client";
+
+function Skeleton({ className = "" }: { className?: string }) {
+  return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
+}
+
+/** Simple form wireframe reused in both diagrams */
+function FormUI() {
+  return (
+    <div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
+      {/* Title */}
+      <Skeleton className="h-2.5 w-14 mb-1" />
+      {/* Input fields */}
+      <Skeleton className="h-4 w-full rounded-sm" />
+      <Skeleton className="h-4 w-full rounded-sm" />
+      {/* Submit button */}
+      <Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
+    </div>
+  );
+}
+
+function ChatModeDiagram() {
+  return (
+    <div className="flex flex-col h-full">
+      <div className="text-xs font-medium text-muted-foreground mb-3 text-center">
+        Chat Mode
+      </div>
+      <div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
+        {/* Chat area */}
+        <div className="flex-1 p-3 overflow-hidden flex justify-center">
+          <div className="w-1/3 min-w-[120px] space-y-3">
+            {/* User message */}
+            <div className="flex justify-end">
+              <div className="bg-muted rounded-xl px-3 py-2">
+                <Skeleton className="h-2 w-14" />
+              </div>
+            </div>
+
+            {/* Assistant text */}
+            <div className="space-y-2">
+              <div className="space-y-1.5">
+                <Skeleton className="h-2 w-full" />
+                <Skeleton className="h-2 w-3/4" />
+              </div>
+
+              {/* Inline UI */}
+              <FormUI />
+
+              {/* More text after UI */}
+              <div className="space-y-1.5">
+                <Skeleton className="h-2 w-4/5" />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {/* Input bar */}
+        <div className="p-2 flex justify-center">
+          <div className="w-1/3 min-w-[120px] flex items-center gap-2">
+            <Skeleton className="h-7 flex-1 rounded-md" />
+            <Skeleton className="h-7 w-7 rounded-md" />
+          </div>
+        </div>
+      </div>
+      <div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
+        Text + UI interleaved in messages
+      </div>
+    </div>
+  );
+}
+
+function GenerateModeDiagram() {
+  return (
+    <div className="flex flex-col h-full">
+      <div className="text-xs font-medium text-muted-foreground mb-3 text-center">
+        Generate Mode
+      </div>
+      <div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
+        {/* Left panel - prompt */}
+        <div className="w-[38%] border-r border-border flex flex-col">
+          <div className="flex-1" />
+          <div className="p-3 space-y-2">
+            <Skeleton className="h-7 w-full rounded-md" />
+            <Skeleton className="h-5 w-16 rounded-md" />
+          </div>
+        </div>
+
+        {/* Right panel - UI preview */}
+        <div className="flex-1 p-3 flex items-center justify-center">
+          <div className="w-3/4">
+            <FormUI />
+          </div>
+        </div>
+      </div>
+      <div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
+        Prompt separate from UI preview
+      </div>
+    </div>
+  );
+}
+
+export function GenerationModesDiagram() {
+  return (
+    <div className="not-prose my-8">
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
+        <div className="h-[280px]">
+          <ChatModeDiagram />
+        </div>
+        <div className="h-[280px]">
+          <GenerateModeDiagram />
+        </div>
+      </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>10.1k</span>
+            <span>10.5k</span>
           </a>
           <ThemeToggle />
         </nav>

+ 5 - 82
apps/web/components/playground.tsx

@@ -18,6 +18,7 @@ import { Header } from "./header";
 import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
 import { PlaygroundRenderer } from "@/lib/render/renderer";
 import { playgroundCatalog } from "@/lib/render/catalog";
+import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
 
 type Tab = "json" | "nested" | "stream" | "catalog";
 type RenderView = "preview" | "code";
@@ -465,88 +466,10 @@ ${jsx}
   );
 
   // 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 };
-  }, []);
+  const catalogData = useMemo(
+    () => buildCatalogDisplayData(playgroundCatalog.data),
+    [],
+  );
 
   // Code pane content
   const copyText =

+ 15 - 3
apps/web/lib/docs-navigation.ts

@@ -16,24 +16,37 @@ export const docsNavigation: NavSection[] = [
       { title: "Introduction", href: "/docs" },
       { title: "Installation", href: "/docs/installation" },
       { title: "Quick Start", href: "/docs/quick-start" },
+      { title: "Migration Guide", href: "/docs/migration" },
       { title: "Changelog", href: "/docs/changelog" },
     ],
   },
   {
-    title: "Core Concepts",
+    title: "Core",
     items: [
       { title: "Specs", href: "/docs/specs" },
       { title: "Schemas", href: "/docs/schemas" },
       { title: "Catalog", href: "/docs/catalog" },
-      { title: "Registry", href: "/docs/registry" },
       { title: "Data Binding", href: "/docs/data-binding" },
       { title: "Visibility", href: "/docs/visibility" },
       { title: "Validation", href: "/docs/validation" },
     ],
   },
+  {
+    title: "Rendering",
+    items: [
+      { title: "Registry", href: "/docs/registry" },
+      { title: "Streaming", href: "/docs/streaming" },
+      { title: "Generation Modes", href: "/docs/generation-modes" },
+    ],
+  },
   {
     title: "Examples",
     items: [
+      {
+        title: "Chat",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
+        external: true,
+      },
       {
         title: "Dashboard",
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
@@ -55,7 +68,6 @@ export const docsNavigation: NavSection[] = [
     title: "Guides",
     items: [
       { title: "Custom Schema", href: "/docs/custom-schema" },
-      { title: "Streaming", href: "/docs/streaming" },
       { title: "Code Export", href: "/docs/code-export" },
     ],
   },

+ 3 - 1
apps/web/lib/page-titles.ts

@@ -3,7 +3,7 @@
  * 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).
+ * Values are display titles (without the "| json-render" suffix  the layout template adds that).
  */
 export const PAGE_TITLES: Record<string, string> = {
   // Home (no slug)
@@ -24,6 +24,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/validation": "Validation",
   "docs/data-binding": "Data Binding",
   "docs/visibility": "Visibility",
+  "docs/generation-modes": "Generation Modes",
   "docs/code-export": "Code Export",
   "docs/custom-schema": "Custom Schema & Renderer",
   "docs/ai-sdk": "AI SDK Integration",
@@ -31,6 +32,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/openapi": "OpenAPI Integration",
   "docs/a2ui": "A2UI Integration",
   "docs/ag-ui": "AG-UI Integration",
+  "docs/migration": "Migration Guide",
   "docs/changelog": "Changelog",
 
   // API references

+ 117 - 0
apps/web/lib/render/catalog-display.ts

@@ -0,0 +1,117 @@
+/**
+ * Shared utility for extracting catalog data for display in the UI.
+ * Used by both the demo and playground components.
+ */
+
+export interface CatalogField {
+  name: string;
+  type: string;
+}
+
+export interface CatalogComponentInfo {
+  name: string;
+  description: string;
+  props: CatalogField[];
+  slots: string[];
+  events: string[];
+}
+
+export interface CatalogActionInfo {
+  name: string;
+  description: string;
+  params: CatalogField[];
+}
+
+export interface CatalogDisplayData {
+  components: CatalogComponentInfo[];
+  actions: CatalogActionInfo[];
+}
+
+/**
+ * Extract field names and types from a Zod schema object.
+ * Supports both Zod v3 and v4 shape formats.
+ */
+export function extractFields(zodObj: unknown): CatalogField[] {
+  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 [];
+  }
+}
+
+/**
+ * Extract display data from a catalog's raw data.
+ * Parses component definitions and action definitions into a
+ * structured format suitable for rendering in the UI.
+ */
+export function buildCatalogDisplayData(
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  rawCatalogData: any,
+): CatalogDisplayData {
+  const components = Object.entries(rawCatalogData.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(rawCatalogData.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 };
+}

+ 61 - 30
apps/web/lib/render/catalog.ts

@@ -67,11 +67,11 @@ export const playgroundCatalog = defineCatalog(schema, {
           }),
         ),
         defaultValue: z.string().nullable(),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
       }),
       events: ["change"],
       description:
-        "Tab navigation. Use statePath to bind the active tab value.",
+        "Tab navigation. Use { $bindState } on value for active tab binding.",
     },
 
     Accordion: {
@@ -297,10 +297,20 @@ export const playgroundCatalog = defineCatalog(schema, {
         name: z.string(),
         type: z.enum(["text", "email", "password", "number"]).nullable(),
         placeholder: z.string().nullable(),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
+        checks: z
+          .array(
+            z.object({
+              type: z.string(),
+              message: z.string(),
+              args: z.record(z.string(), z.unknown()).optional(),
+            }),
+          )
+          .nullable(),
       }),
       events: ["submit", "focus", "blur"],
-      description: "Text input field. Use statePath for two-way binding.",
+      description:
+        "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
       example: {
         label: "Email",
         name: "email",
@@ -315,9 +325,19 @@ export const playgroundCatalog = defineCatalog(schema, {
         name: z.string(),
         placeholder: z.string().nullable(),
         rows: z.number().nullable(),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
+        checks: z
+          .array(
+            z.object({
+              type: z.string(),
+              message: z.string(),
+              args: z.record(z.string(), z.unknown()).optional(),
+            }),
+          )
+          .nullable(),
       }),
-      description: "Multi-line text input. Use statePath for binding.",
+      description:
+        "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
     },
 
     Select: {
@@ -326,10 +346,20 @@ export const playgroundCatalog = defineCatalog(schema, {
         name: z.string(),
         options: z.array(z.string()),
         placeholder: z.string().nullable(),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
+        checks: z
+          .array(
+            z.object({
+              type: z.string(),
+              message: z.string(),
+              args: z.record(z.string(), z.unknown()).optional(),
+            }),
+          )
+          .nullable(),
       }),
       events: ["change"],
-      description: "Dropdown select input. Use statePath for binding.",
+      description:
+        "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
     },
 
     Checkbox: {
@@ -337,10 +367,9 @@ export const playgroundCatalog = defineCatalog(schema, {
         label: z.string(),
         name: z.string(),
         checked: z.boolean().nullable(),
-        statePath: z.string().nullable(),
       }),
       events: ["change"],
-      description: "Checkbox input. Use statePath for binding.",
+      description: "Checkbox input. Use { $bindState } on checked for binding.",
     },
 
     Radio: {
@@ -348,10 +377,11 @@ export const playgroundCatalog = defineCatalog(schema, {
         label: z.string(),
         name: z.string(),
         options: z.array(z.string()),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
       }),
       events: ["change"],
-      description: "Radio button group. Use statePath for binding.",
+      description:
+        "Radio button group. Use { $bindState } on value for binding.",
     },
 
     Switch: {
@@ -359,10 +389,9 @@ export const playgroundCatalog = defineCatalog(schema, {
         label: z.string(),
         name: z.string(),
         checked: z.boolean().nullable(),
-        statePath: z.string().nullable(),
       }),
       events: ["change"],
-      description: "Toggle switch. Use statePath for binding.",
+      description: "Toggle switch. Use { $bindState } on checked for binding.",
     },
 
     Slider: {
@@ -371,10 +400,11 @@ export const playgroundCatalog = defineCatalog(schema, {
         min: z.number().nullable(),
         max: z.number().nullable(),
         step: z.number().nullable(),
-        statePath: z.string().nullable(),
+        value: z.number().nullable(),
       }),
       events: ["change"],
-      description: "Range slider input. Use statePath for binding.",
+      description:
+        "Range slider input. Use { $bindState } on value for binding.",
     },
 
     // ── Actions ─────────────────────────────────────────────────────────
@@ -416,11 +446,11 @@ export const playgroundCatalog = defineCatalog(schema, {
       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.",
+      description:
+        "Toggle button. Use { $bindState } on pressed for state binding.",
     },
 
     ToggleGroup: {
@@ -432,11 +462,11 @@ export const playgroundCatalog = defineCatalog(schema, {
           }),
         ),
         type: z.enum(["single", "multiple"]).nullable(),
-        statePath: z.string().nullable(),
+        value: z.string().nullable(),
       }),
       events: ["change"],
       description:
-        "Group of toggle buttons. Type 'single' (default) or 'multiple'.",
+        "Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
     },
 
     ButtonGroup: {
@@ -447,45 +477,46 @@ export const playgroundCatalog = defineCatalog(schema, {
             value: z.string(),
           }),
         ),
-        statePath: z.string().nullable(),
+        selected: z.string().nullable(),
       }),
       events: ["change"],
-      description: "Segmented button group. Use statePath for selected value.",
+      description:
+        "Segmented button group. Use { $bindState } on selected for selected value.",
     },
 
     Pagination: {
       props: z.object({
         totalPages: z.number(),
-        statePath: z.string(),
+        page: z.number().nullable(),
       }),
       events: ["change"],
       description:
-        "Page navigation. Bind statePath to a number for current page.",
+        "Page navigation. Use { $bindState } on page for current page number.",
     },
   },
 
   actions: {
     setState: {
       params: z.object({
-        path: z.string(),
+        statePath: z.string(),
         value: z.unknown(),
       }),
-      description: "Update a value in the state model at the given path.",
+      description: "Update a value in the state model at the given statePath.",
     },
 
     pushState: {
       params: z.object({
-        path: z.string(),
+        statePath: z.string(),
         value: z.unknown(),
-        clearPath: z.string().optional(),
+        clearStatePath: 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.',
+        'Append an item to an array in state. Value can contain {"$state":"/statePath"} refs and "$id" for auto IDs. clearStatePath resets another path after pushing.',
     },
 
     removeState: {
       params: z.object({
-        path: z.string(),
+        statePath: z.string(),
         index: z.number(),
       }),
       description: "Remove an item from an array in state at the given index.",

+ 151 - 99
apps/web/lib/render/registry.tsx

@@ -1,7 +1,12 @@
 "use client";
 
 import { useState } from "react";
-import { defineRegistry, useStateBinding } from "@json-render/react";
+import {
+  defineRegistry,
+  useBoundProp,
+  useStateBinding,
+  useFieldValidation,
+} from "@json-render/react";
 import { toast } from "sonner";
 
 import { playgroundCatalog } from "./catalog";
@@ -210,25 +215,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       />
     ),
 
-    Tabs: ({ props, emit }) => {
+    Tabs: ({ props, bindings, 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 [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState(
         props.defaultValue ?? tabs[0]?.value ?? "",
       );
-      const value = props.statePath
-        ? (boundValue ?? tabs[0]?.value ?? "")
-        : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
 
       return (
         <TabsPrimitive
           value={value}
           onValueChange={(v) => {
             setValue(v);
-            emit?.("change");
+            emit("change");
           }}
         >
           <TabsList>
@@ -765,13 +770,20 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
 
     // ── Form Inputs ───────────────────────────────────────────────────
 
-    Input: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Input: ({ props, bindings, emit }) => {
+      const [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState("");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
+
+      const hasValidation = !!(bindings?.value && props.checks?.length);
+      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
+        checks: props.checks ?? [],
+      });
 
       return (
         <div className="space-y-2">
@@ -784,22 +796,35 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             value={value}
             onChange={(e) => setValue(e.target.value)}
             onKeyDown={(e) => {
-              if (e.key === "Enter") emit?.("submit");
+              if (e.key === "Enter") emit("submit");
+            }}
+            onFocus={() => emit("focus")}
+            onBlur={() => {
+              if (hasValidation) validate();
+              emit("blur");
             }}
-            onFocus={() => emit?.("focus")}
-            onBlur={() => emit?.("blur")}
           />
+          {errors.length > 0 && (
+            <p className="text-sm text-destructive">{errors[0]}</p>
+          )}
         </div>
       );
     },
 
-    Textarea: ({ props }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Textarea: ({ props, bindings }) => {
+      const [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState("");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
+
+      const hasValidation = !!(bindings?.value && props.checks?.length);
+      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
+        checks: props.checks ?? [],
+      });
 
       return (
         <div className="space-y-2">
@@ -811,18 +836,26 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             rows={props.rows ?? 3}
             value={value}
             onChange={(e) => setValue(e.target.value)}
+            onBlur={() => {
+              if (hasValidation) validate();
+            }}
           />
+          {errors.length > 0 && (
+            <p className="text-sm text-destructive">{errors[0]}</p>
+          )}
         </div>
       );
     },
 
-    Select: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Select: ({ props, bindings, emit }) => {
+      const [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState<string>("");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
       const rawOptions = props.options ?? [];
       // Coerce options to strings – AI may produce objects/numbers instead of
       // plain strings which would cause duplicate `[object Object]` keys.
@@ -830,6 +863,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
         typeof opt === "string" ? opt : String(opt ?? ""),
       );
 
+      const hasValidation = !!(bindings?.value && props.checks?.length);
+      const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
+        checks: props.checks ?? [],
+      });
+
       return (
         <div className="space-y-2">
           <Label>{props.label}</Label>
@@ -837,7 +875,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             value={value}
             onValueChange={(v) => {
               setValue(v);
-              emit?.("change");
+              if (hasValidation) validate();
+              emit("change");
             }}
           >
             <SelectTrigger className="w-full">
@@ -854,17 +893,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               ))}
             </SelectContent>
           </Select>
+          {errors.length > 0 && (
+            <p className="text-sm text-destructive">{errors[0]}</p>
+          )}
         </div>
       );
     },
 
-    Checkbox: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Checkbox: ({ props, bindings, emit }) => {
+      const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
+        props.checked as boolean | undefined,
+        bindings?.checked,
+      );
       const [localChecked, setLocalChecked] = useState(!!props.checked);
-      const checked = props.statePath ? (boundValue ?? false) : localChecked;
-      const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
+      const isBound = !!bindings?.checked;
+      const checked = isBound ? (boundChecked ?? false) : localChecked;
+      const setChecked = isBound ? setBoundChecked : setLocalChecked;
 
       return (
         <div className="flex items-center space-x-2">
@@ -873,7 +917,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             checked={checked}
             onCheckedChange={(c) => {
               setChecked(c === true);
-              emit?.("change");
+              emit("change");
             }}
           />
           <Label htmlFor={props.name} className="cursor-pointer">
@@ -883,17 +927,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    Radio: ({ props, emit }) => {
+    Radio: ({ props, bindings, emit }) => {
       const rawOptions = props.options ?? [];
       const options = rawOptions.map((opt) =>
         typeof opt === "string" ? opt : String(opt ?? ""),
       );
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+      const [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState(options[0] ?? "");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
 
       return (
         <div className="space-y-2">
@@ -902,7 +948,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             value={value}
             onValueChange={(v) => {
               setValue(v);
-              emit?.("change");
+              emit("change");
             }}
           >
             {options.map((opt, idx) => (
@@ -927,13 +973,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    Switch: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Switch: ({ props, bindings, emit }) => {
+      const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
+        props.checked as boolean | undefined,
+        bindings?.checked,
+      );
       const [localChecked, setLocalChecked] = useState(!!props.checked);
-      const checked = props.statePath ? (boundValue ?? false) : localChecked;
-      const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
+      const isBound = !!bindings?.checked;
+      const checked = isBound ? (boundChecked ?? false) : localChecked;
+      const setChecked = isBound ? setBoundChecked : setLocalChecked;
 
       return (
         <div className="flex items-center justify-between space-x-2">
@@ -945,22 +993,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             checked={checked}
             onCheckedChange={(c) => {
               setChecked(c);
-              emit?.("change");
+              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];
+    Slider: ({ props, bindings, emit }) => {
+      const [boundValue, setBoundValue] = useBoundProp<number>(
+        props.value as number | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState(props.min ?? 0);
-      const value = props.statePath
-        ? (boundValue ?? props.min ?? 0)
-        : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
 
       return (
         <div className="space-y-2">
@@ -977,7 +1025,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
             step={props.step ?? 1}
             onValueChange={(v) => {
               setValue(v[0] ?? 0);
-              emit?.("change");
+              emit("change");
             }}
           />
         </div>
@@ -998,7 +1046,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
         <Button
           variant={variant}
           disabled={props.disabled ?? false}
-          onClick={() => emit?.("press")}
+          onClick={() => emit("press")}
         >
           {props.label}
         </Button>
@@ -1009,7 +1057,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       <Button
         variant="link"
         className="h-auto p-0"
-        onClick={() => emit?.("press")}
+        onClick={() => emit("press")}
       >
         {props.label}
       </Button>
@@ -1024,10 +1072,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
           </DropdownMenuTrigger>
           <DropdownMenuContent>
             {items.map((item) => (
-              <DropdownMenuItem
-                key={item.value}
-                onClick={() => emit?.("select")}
-              >
+              <DropdownMenuItem key={item.value} onClick={() => emit("select")}>
                 {item.label}
               </DropdownMenuItem>
             ))}
@@ -1036,13 +1081,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    Toggle: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = props.statePath
-        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
-        : [undefined, undefined];
+    Toggle: ({ props, bindings, emit }) => {
+      const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
+        props.pressed as boolean | undefined,
+        bindings?.pressed,
+      );
       const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
-      const pressed = props.statePath ? (boundValue ?? false) : localPressed;
-      const setPressed = props.statePath ? setBoundValue! : setLocalPressed;
+      const isBound = !!bindings?.pressed;
+      const pressed = isBound ? (boundPressed ?? false) : localPressed;
+      const setPressed = isBound ? setBoundPressed : setLocalPressed;
 
       return (
         <Toggle
@@ -1050,7 +1097,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
           pressed={pressed}
           onPressedChange={(v) => {
             setPressed(v);
-            emit?.("change");
+            emit("change");
           }}
         >
           {props.label}
@@ -1058,15 +1105,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    ToggleGroup: ({ props, emit }) => {
+    ToggleGroup: ({ props, bindings, 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 [boundValue, setBoundValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
       const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.value;
+      const value = isBound ? (boundValue ?? "") : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
 
       if (type === "multiple") {
         return (
@@ -1087,7 +1136,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
           onValueChange={(v) => {
             if (v) {
               setValue(v);
-              emit?.("change");
+              emit("change");
             }
           }}
         >
@@ -1100,14 +1149,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    ButtonGroup: ({ props, emit }) => {
+    ButtonGroup: ({ props, bindings, 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 [boundSelected, setBoundSelected] = useBoundProp<string>(
+        props.selected as string | undefined,
+        bindings?.selected,
+      );
       const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
-      const value = props.statePath ? (boundValue ?? "") : localValue;
-      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const isBound = !!bindings?.selected;
+      const value = isBound ? (boundSelected ?? "") : localValue;
+      const setValue = isBound ? setBoundSelected : setLocalValue;
 
       return (
         <div className="inline-flex rounded-md border border-border">
@@ -1123,7 +1174,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               } ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
               onClick={() => {
                 setValue(btn.value);
-                emit?.("change");
+                emit("change");
               }}
             >
               {btn.label}
@@ -1133,11 +1184,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
-    Pagination: ({ props, emit }) => {
-      const [boundValue, setBoundValue] = useStateBinding<number>(
-        props.statePath,
+    Pagination: ({ props, bindings, emit }) => {
+      const [boundPage, setBoundPage] = useBoundProp<number>(
+        props.page as number | undefined,
+        bindings?.page,
       );
-      const currentPage = boundValue ?? 1;
+      const currentPage = boundPage ?? 1;
       const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
 
       return (
@@ -1149,8 +1201,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
                 onClick={(e) => {
                   e.preventDefault();
                   if (currentPage > 1) {
-                    setBoundValue(currentPage - 1);
-                    emit?.("change");
+                    setBoundPage(currentPage - 1);
+                    emit("change");
                   }
                 }}
               />
@@ -1162,8 +1214,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
                   isActive={page === currentPage}
                   onClick={(e) => {
                     e.preventDefault();
-                    setBoundValue(page);
-                    emit?.("change");
+                    setBoundPage(page);
+                    emit("change");
                   }}
                 >
                   {page}
@@ -1176,8 +1228,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
                 onClick={(e) => {
                   e.preventDefault();
                   if (currentPage < props.totalPages) {
-                    setBoundValue(currentPage + 1);
-                    emit?.("change");
+                    setBoundPage(currentPage + 1);
+                    emit("change");
                   }
                 }}
               />

+ 50 - 29
apps/web/lib/render/renderer.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import type { ReactNode } from "react";
+import { useRef, useMemo, type ReactNode } from "react";
 import { toast } from "sonner";
 import {
   Renderer,
@@ -8,6 +8,8 @@ import {
   StateProvider,
   VisibilityProvider,
   ActionProvider,
+  ValidationProvider,
+  useValidation,
 } from "@json-render/react";
 
 import { registry, Fallback } from "./registry";
@@ -27,27 +29,44 @@ const fallbackRenderer = (renderProps: { element: { type: string } }) => (
 );
 
 /**
- * Action handlers for the playground preview.
- * These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
- * linkClick) work when triggered from the rendered UI.
+ * Inner component that sits inside ValidationProvider so it can call
+ * useValidation() and wire validateAll into the formSubmit action handler.
+ *
+ * ActionProvider stores `handlers` in useState, so it only reads the initial
+ * value. We use a ref so the handlers object is stable (created once) but
+ * formSubmit always reads the latest validateAll.
  */
-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}`);
-  },
-};
+function ValidatedActions({ children }: { children: ReactNode }) {
+  const { validateAll } = useValidation();
+  const validateAllRef = useRef(validateAll);
+  validateAllRef.current = validateAll;
+
+  const handlers = useMemo<
+    Record<string, (params: Record<string, unknown>) => void>
+  >(
+    () => ({
+      buttonClick: (params) => {
+        const message = (params?.message as string) || "Button clicked!";
+        toast.success(message);
+      },
+      formSubmit: () => {
+        const allValid = validateAllRef.current();
+        if (!allValid) {
+          toast.error("Please fix the errors before submitting.");
+          return;
+        }
+        toast.success("Form submitted successfully!");
+      },
+      linkClick: (params) => {
+        const href = (params?.href as string) || "#";
+        toast.info(`Navigating to: ${href}`);
+      },
+    }),
+    [], // stable — ref ensures latest validateAll is always used
+  );
+
+  return <ActionProvider handlers={handlers}>{children}</ActionProvider>;
+}
 
 export function PlaygroundRenderer({
   spec,
@@ -59,14 +78,16 @@ export function PlaygroundRenderer({
   return (
     <StateProvider initialState={data ?? spec.state}>
       <VisibilityProvider>
-        <ActionProvider handlers={actionHandlers}>
-          <Renderer
-            spec={spec}
-            registry={registry}
-            fallback={fallbackRenderer}
-            loading={loading}
-          />
-        </ActionProvider>
+        <ValidationProvider>
+          <ValidatedActions>
+            <Renderer
+              spec={spec}
+              registry={registry}
+              fallback={fallbackRenderer}
+              loading={loading}
+            />
+          </ValidatedActions>
+        </ValidationProvider>
       </VisibilityProvider>
     </StateProvider>
   );

+ 5 - 1
apps/web/mdx-components.tsx

@@ -1,6 +1,7 @@
 import type { MDXComponents } from "mdx/types";
 import Link from "next/link";
 import { Code } from "@/components/code";
+import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
 import { PackageInstall } from "@/components/package-install";
 
 function slugify(text: string): string {
@@ -130,7 +131,9 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
     ),
     table: ({ children }: { children?: React.ReactNode }) => (
       <div className="my-6 overflow-x-auto">
-        <table className="w-full text-sm border-collapse">{children}</table>
+        <table className="mdx-table w-full text-sm border-collapse">
+          {children}
+        </table>
       </div>
     ),
     th: ({ children }: { children?: React.ReactNode }) => (
@@ -145,6 +148,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
     ),
     em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
     // Custom components available in all MDX files
+    GenerationModesDiagram,
     PackageInstall,
   };
 }

+ 2 - 0
apps/web/package.json

@@ -41,10 +41,12 @@
     "react": "19.2.3",
     "react-dom": "19.2.3",
     "react-resizable-panels": "^4.4.1",
+    "remark-gfm": "4.0.1",
     "shiki": "^3.21.0",
     "sonner": "^2.0.7",
     "streamdown": "2.1.0",
     "tailwind-merge": "^3.4.0",
+    "unist-util-visit": "5.1.0",
     "vaul": "^1.1.2",
     "zod": "^4.0.0"
   },

+ 36 - 0
examples/chat/app/api/generate/route.ts

@@ -0,0 +1,36 @@
+import { agent } from "@/lib/agent";
+import {
+  convertToModelMessages,
+  createUIMessageStream,
+  createUIMessageStreamResponse,
+  type UIMessage,
+} from "ai";
+import { pipeJsonRender } from "@json-render/core";
+
+export const maxDuration = 60;
+
+export async function POST(req: Request) {
+  const body = await req.json();
+  const uiMessages: UIMessage[] = body.messages;
+
+  if (!uiMessages || !Array.isArray(uiMessages) || uiMessages.length === 0) {
+    return new Response(
+      JSON.stringify({ error: "messages array is required" }),
+      {
+        status: 400,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const modelMessages = await convertToModelMessages(uiMessages);
+  const result = await agent.stream({ messages: modelMessages });
+
+  const stream = createUIMessageStream({
+    execute: async ({ writer }) => {
+      writer.merge(pipeJsonRender(result.toUIMessageStream()));
+    },
+  });
+
+  return createUIMessageStreamResponse({ stream });
+}

+ 128 - 0
examples/chat/app/globals.css

@@ -0,0 +1,128 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../../node_modules/streamdown/dist/*.js";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --radius-2xl: calc(var(--radius) + 8px);
+  --radius-3xl: calc(var(--radius) + 12px);
+  --radius-4xl: calc(var(--radius) + 16px);
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+}
+
+:root {
+  --radius: 0.625rem;
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.97 0 0);
+  --muted-foreground: oklch(0.556 0 0);
+  --accent: oklch(0.97 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.577 0.245 27.325);
+  --border: oklch(0.922 0 0);
+  --input: oklch(0.922 0 0);
+  --ring: oklch(0.708 0 0);
+  --chart-1: oklch(0.646 0.222 41.116);
+  --chart-2: oklch(0.6 0.118 184.704);
+  --chart-3: oklch(0.398 0.07 227.392);
+  --chart-4: oklch(0.828 0.189 84.429);
+  --chart-5: oklch(0.769 0.188 70.08);
+}
+
+.dark {
+  --background: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.205 0 0);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.922 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.269 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.269 0 0);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.216);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.556 0 0);
+  --chart-1: oklch(0.488 0.243 264.376);
+  --chart-2: oklch(0.696 0.17 162.48);
+  --chart-3: oklch(0.769 0.188 70.08);
+  --chart-4: oklch(0.627 0.265 303.9);
+  --chart-5: oklch(0.645 0.246 16.439);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
+
+button {
+  cursor: pointer;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -200% 0;
+  }
+  100% {
+    background-position: 200% 0;
+  }
+}
+
+.animate-shimmer {
+  background: linear-gradient(
+    90deg,
+    currentColor 25%,
+    hsl(0 0% 64%) 50%,
+    currentColor 75%
+  );
+  background-size: 200% 100%;
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+  animation: shimmer 2s ease-in-out infinite;
+}

+ 40 - 0
examples/chat/app/layout.tsx

@@ -0,0 +1,40 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import { Toaster } from "sonner";
+import { ThemeProvider } from "@/components/theme-provider";
+import "streamdown/styles.css";
+import "./globals.css";
+
+const geistSans = Geist({
+  variable: "--font-geist-sans",
+  subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+  variable: "--font-geist-mono",
+  subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+  title: "json-render Chat Example",
+  description: "AI-powered data explorer using ToolLoopAgent and json-render",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en" suppressHydrationWarning>
+      <body
+        className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
+      >
+        <ThemeProvider>
+          {children}
+          <Toaster />
+        </ThemeProvider>
+      </body>
+    </html>
+  );
+}

+ 493 - 0
examples/chat/app/page.tsx

@@ -0,0 +1,493 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport, type UIMessage } from "ai";
+import {
+  SPEC_DATA_PART,
+  SPEC_DATA_PART_TYPE,
+  type SpecDataPart,
+} from "@json-render/core";
+import { useJsonRenderMessage } from "@json-render/react";
+import { ExplorerRenderer } from "@/lib/render/renderer";
+import { ThemeToggle } from "@/components/theme-toggle";
+import {
+  ArrowDown,
+  ArrowUp,
+  ChevronRight,
+  Loader2,
+  Sparkles,
+} from "lucide-react";
+import { Streamdown } from "streamdown";
+import { code } from "@streamdown/code";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
+type AppMessage = UIMessage<unknown, AppDataParts>;
+
+// =============================================================================
+// Transport
+// =============================================================================
+
+const transport = new DefaultChatTransport({ api: "/api/generate" });
+
+// =============================================================================
+// Suggestions (shown in empty state)
+// =============================================================================
+
+const SUGGESTIONS = [
+  {
+    label: "Weather comparison",
+    prompt: "Compare the weather in New York, London, and Tokyo",
+  },
+  {
+    label: "GitHub repo stats",
+    prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
+  },
+  {
+    label: "Crypto dashboard",
+    prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
+  },
+  {
+    label: "Hacker News top stories",
+    prompt: "Show me the top 15 Hacker News stories right now",
+  },
+];
+
+// =============================================================================
+// Tool Call Display
+// =============================================================================
+
+/** Readable labels for tool names: [loading, done] */
+const TOOL_LABELS: Record<string, [string, string]> = {
+  getWeather: ["Getting weather data", "Got weather data"],
+  getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
+  getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
+  getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
+  getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
+  getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
+  webSearch: ["Searching the web", "Searched the web"],
+};
+
+function ToolCallDisplay({
+  toolName,
+  state,
+  result,
+}: {
+  toolName: string;
+  state: string;
+  result: unknown;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const isLoading =
+    state !== "output-available" &&
+    state !== "output-error" &&
+    state !== "output-denied";
+  const labels = TOOL_LABELS[toolName];
+  const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
+
+  return (
+    <div className="text-sm group">
+      <button
+        type="button"
+        className="flex items-center gap-1.5"
+        onClick={() => setExpanded((e) => !e)}
+      >
+        <span
+          className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
+        >
+          {label}
+        </span>
+        {!isLoading && (
+          <ChevronRight
+            className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
+          />
+        )}
+      </button>
+      {expanded && !isLoading && result != null && (
+        <div className="mt-1 max-h-64 overflow-auto">
+          <pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
+            {typeof result === "string"
+              ? result
+              : JSON.stringify(result, null, 2)}
+          </pre>
+        </div>
+      )}
+    </div>
+  );
+}
+
+// =============================================================================
+// Message Bubble
+// =============================================================================
+
+function MessageBubble({
+  message,
+  isLast,
+  isStreaming,
+}: {
+  message: AppMessage;
+  isLast: boolean;
+  isStreaming: boolean;
+}) {
+  const isUser = message.role === "user";
+  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
+
+  // Build ordered segments from parts, collapsing adjacent text and adjacent tools.
+  // Spec data parts are tracked so the rendered UI appears inline where the AI
+  // placed it rather than always at the bottom.
+  const segments: Array<
+    | { kind: "text"; text: string }
+    | {
+        kind: "tools";
+        tools: Array<{
+          toolCallId: string;
+          toolName: string;
+          state: string;
+          output?: unknown;
+        }>;
+      }
+    | { kind: "spec" }
+  > = [];
+
+  let specInserted = false;
+
+  for (const part of message.parts) {
+    if (part.type === "text") {
+      if (!part.text.trim()) continue;
+      const last = segments[segments.length - 1];
+      if (last?.kind === "text") {
+        last.text += part.text;
+      } else {
+        segments.push({ kind: "text", text: part.text });
+      }
+    } else if (part.type.startsWith("tool-")) {
+      const tp = part as {
+        type: string;
+        toolCallId: string;
+        state: string;
+        output?: unknown;
+      };
+      const last = segments[segments.length - 1];
+      if (last?.kind === "tools") {
+        last.tools.push({
+          toolCallId: tp.toolCallId,
+          toolName: tp.type.replace(/^tool-/, ""),
+          state: tp.state,
+          output: tp.output,
+        });
+      } else {
+        segments.push({
+          kind: "tools",
+          tools: [
+            {
+              toolCallId: tp.toolCallId,
+              toolName: tp.type.replace(/^tool-/, ""),
+              state: tp.state,
+              output: tp.output,
+            },
+          ],
+        });
+      }
+    } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
+      // First spec data part — mark where the rendered UI should appear
+      segments.push({ kind: "spec" });
+      specInserted = true;
+    }
+  }
+
+  const hasAnything = segments.length > 0 || hasSpec;
+  const showLoader =
+    isLast && isStreaming && message.role === "assistant" && !hasAnything;
+
+  if (isUser) {
+    return (
+      <div className="flex justify-end">
+        {text && (
+          <div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
+            {text}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  // If there's a spec but no spec segment was inserted (edge case),
+  // append it so it still renders.
+  const specRenderedInline = specInserted;
+  const showSpecAtEnd = hasSpec && !specRenderedInline;
+
+  return (
+    <div className="w-full flex flex-col gap-3">
+      {segments.map((seg, i) => {
+        if (seg.kind === "text") {
+          const isLastSegment = i === segments.length - 1;
+          return (
+            <div
+              key={`text-${i}`}
+              className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
+            >
+              <Streamdown
+                plugins={{ code }}
+                animated={isLast && isStreaming && isLastSegment}
+              >
+                {seg.text}
+              </Streamdown>
+            </div>
+          );
+        }
+        if (seg.kind === "spec") {
+          if (!hasSpec) return null;
+          return (
+            <div key="spec" className="w-full">
+              <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
+            </div>
+          );
+        }
+        return (
+          <div key={`tools-${i}`} className="flex flex-col gap-1">
+            {seg.tools.map((t) => (
+              <ToolCallDisplay
+                key={t.toolCallId}
+                toolName={t.toolName}
+                state={t.state}
+                result={t.output}
+              />
+            ))}
+          </div>
+        );
+      })}
+
+      {/* Loading indicator */}
+      {showLoader && (
+        <div className="text-sm text-muted-foreground animate-shimmer">
+          Thinking...
+        </div>
+      )}
+
+      {/* Fallback: render spec at end if no inline position was found */}
+      {showSpecAtEnd && (
+        <div className="w-full">
+          <ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
+        </div>
+      )}
+    </div>
+  );
+}
+
+// =============================================================================
+// Page
+// =============================================================================
+
+export default function ChatPage() {
+  const [input, setInput] = useState("");
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const scrollContainerRef = useRef<HTMLElement>(null);
+  const [showScrollButton, setShowScrollButton] = useState(false);
+  const isStickToBottom = useRef(true);
+  const isAutoScrolling = useRef(false);
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+
+  const { messages, sendMessage, setMessages, status, error } =
+    useChat<AppMessage>({ transport });
+
+  const isStreaming = status === "streaming" || status === "submitted";
+
+  // Track whether the user has scrolled away from the bottom.
+  // During programmatic scrolling, suppress button updates until we arrive.
+  useEffect(() => {
+    const container = scrollContainerRef.current;
+    if (!container) return;
+    const THRESHOLD = 80;
+    const handleScroll = () => {
+      const { scrollTop, scrollHeight, clientHeight } = container;
+      const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
+
+      if (isAutoScrolling.current) {
+        // Wait for the programmatic scroll to reach the bottom before
+        // handing control back to the user-scroll tracker.
+        if (atBottom) {
+          isAutoScrolling.current = false;
+        }
+        return;
+      }
+
+      isStickToBottom.current = atBottom;
+      setShowScrollButton(!atBottom);
+    };
+    container.addEventListener("scroll", handleScroll, { passive: true });
+    return () => container.removeEventListener("scroll", handleScroll);
+  }, []);
+
+  // Auto-scroll to bottom on new messages, unless user scrolled up.
+  // Uses instant scrollTop assignment (no smooth animation) to avoid
+  // an ongoing animation that fights user scroll input.
+  useEffect(() => {
+    const container = scrollContainerRef.current;
+    if (!container || !isStickToBottom.current) return;
+    isAutoScrolling.current = true;
+    container.scrollTop = container.scrollHeight;
+    requestAnimationFrame(() => {
+      isAutoScrolling.current = false;
+    });
+  }, [messages, isStreaming]);
+
+  const scrollToBottom = useCallback(() => {
+    const container = scrollContainerRef.current;
+    if (!container) return;
+    isStickToBottom.current = true;
+    setShowScrollButton(false);
+    isAutoScrolling.current = true;
+    container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
+    // isAutoScrolling is cleared by the scroll handler once it reaches bottom
+  }, []);
+
+  const handleSubmit = useCallback(
+    async (text?: string) => {
+      const message = text || input;
+      if (!message.trim() || isStreaming) return;
+      setInput("");
+      await sendMessage({ text: message.trim() });
+    },
+    [input, isStreaming, sendMessage],
+  );
+
+  const handleKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        handleSubmit();
+      }
+    },
+    [handleSubmit],
+  );
+
+  const handleClear = useCallback(() => {
+    setMessages([]);
+    setInput("");
+    inputRef.current?.focus();
+  }, [setMessages]);
+
+  const isEmpty = messages.length === 0;
+
+  return (
+    <div className="h-screen flex flex-col overflow-hidden">
+      {/* Header */}
+      <header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
+        <div className="flex items-center gap-3">
+          <h1 className="text-lg font-semibold">json-render Chat Example</h1>
+        </div>
+        <div className="flex items-center gap-2">
+          {messages.length > 0 && (
+            <button
+              onClick={handleClear}
+              className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
+            >
+              Start Over
+            </button>
+          )}
+          <ThemeToggle />
+        </div>
+      </header>
+
+      {/* Messages area */}
+      <main ref={scrollContainerRef} className="flex-1 overflow-auto">
+        {isEmpty ? (
+          /* Empty state */
+          <div className="h-full flex flex-col items-center justify-center px-6 py-12">
+            <div className="max-w-2xl w-full space-y-8">
+              <div className="text-center space-y-2">
+                <h2 className="text-2xl font-semibold tracking-tight">
+                  What would you like to explore?
+                </h2>
+                <p className="text-muted-foreground">
+                  Ask about weather, GitHub repos, crypto prices, or Hacker News
+                  -- the agent will fetch real data and build a dashboard.
+                </p>
+              </div>
+
+              {/* Suggestions */}
+              <div className="flex flex-wrap gap-2 justify-center">
+                {SUGGESTIONS.map((s) => (
+                  <button
+                    key={s.label}
+                    onClick={() => handleSubmit(s.prompt)}
+                    className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
+                  >
+                    <Sparkles className="h-3 w-3" />
+                    {s.label}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </div>
+        ) : (
+          /* Message thread */
+          <div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
+            {messages.map((message, index) => (
+              <MessageBubble
+                key={message.id}
+                message={message}
+                isLast={index === messages.length - 1}
+                isStreaming={isStreaming}
+              />
+            ))}
+
+            {/* Error display */}
+            {error && (
+              <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
+                {error.message}
+              </div>
+            )}
+
+            <div ref={messagesEndRef} />
+          </div>
+        )}
+      </main>
+
+      {/* Input bar - always visible at bottom */}
+      <div className="px-6 pb-3 flex-shrink-0 bg-background relative">
+        {/* Scroll to bottom button */}
+        {showScrollButton && !isEmpty && (
+          <button
+            onClick={scrollToBottom}
+            className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
+            aria-label="Scroll to bottom"
+          >
+            <ArrowDown className="h-4 w-4" />
+          </button>
+        )}
+        <div className="max-w-4xl mx-auto relative">
+          <textarea
+            ref={inputRef}
+            value={input}
+            onChange={(e) => setInput(e.target.value)}
+            onKeyDown={handleKeyDown}
+            placeholder={
+              isEmpty
+                ? "e.g., Compare weather in NYC, London, and Tokyo..."
+                : "Ask a follow-up..."
+            }
+            rows={2}
+            className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+            autoFocus
+          />
+          <button
+            onClick={() => handleSubmit()}
+            disabled={!input.trim() || isStreaming}
+            className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+          >
+            {isStreaming ? (
+              <Loader2 className="h-4 w-4 animate-spin" />
+            ) : (
+              <ArrowUp className="h-4 w-4" />
+            )}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 16 - 0
examples/chat/components/theme-provider.tsx

@@ -0,0 +1,16 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+  return (
+    <NextThemesProvider
+      attribute="class"
+      defaultTheme="system"
+      enableSystem
+      disableTransitionOnChange
+    >
+      {children}
+    </NextThemesProvider>
+  );
+}

+ 36 - 0
examples/chat/components/theme-toggle.tsx

@@ -0,0 +1,36 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+import { Moon, Sun } from "lucide-react";
+
+export function ThemeToggle() {
+  const { theme, setTheme } = useTheme();
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  if (!mounted) {
+    return (
+      <button className="p-2 rounded-md border border-border bg-card">
+        <Sun className="h-4 w-4" />
+      </button>
+    );
+  }
+
+  return (
+    <button
+      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
+      className="p-2 rounded-md border border-border bg-card hover:bg-accent transition-colors"
+      aria-label="Toggle theme"
+    >
+      {theme === "dark" ? (
+        <Sun className="h-4 w-4" />
+      ) : (
+        <Moon className="h-4 w-4" />
+      )}
+    </button>
+  );
+}

+ 73 - 0
examples/chat/components/ui/accordion.tsx

@@ -0,0 +1,73 @@
+"use client";
+
+import * as React from "react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+import { ChevronDownIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Accordion({
+  className,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
+  return (
+    <AccordionPrimitive.Root
+      data-slot="accordion"
+      className={cn("w-full", className)}
+      {...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] [&[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 };

+ 66 - 0
examples/chat/components/ui/alert.tsx

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

+ 48 - 0
examples/chat/components/ui/badge.tsx

@@ -0,0 +1,48 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+        ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+        link: "text-primary underline-offset-4 [a&]:hover:underline",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function Badge({
+  className,
+  variant = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot.Root : "span";
+
+  return (
+    <Comp
+      data-slot="badge"
+      data-variant={variant}
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+export { Badge, badgeVariants };

+ 57 - 0
examples/chat/components/ui/button.tsx

@@ -0,0 +1,57 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+        outline:
+          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost: "hover:bg-accent hover:text-accent-foreground",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-10 px-4 py-2",
+        sm: "h-9 rounded-md px-3",
+        lg: "h-11 rounded-md px-8",
+        icon: "h-10 w-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+interface ButtonProps
+  extends
+    React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {
+  asChild?: boolean;
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const Comp = asChild ? Slot.Root : "button";
+    return (
+      <Comp
+        className={cn(buttonVariants({ variant, size, className }))}
+        ref={ref}
+        {...props}
+      />
+    );
+  },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
+export type { ButtonProps };

+ 92 - 0
examples/chat/components/ui/card.tsx

@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card"
+      className={cn(
+        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn("leading-none font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-action"
+      className={cn(
+        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn("px-6", className)}
+      {...props}
+    />
+  );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Card,
+  CardHeader,
+  CardFooter,
+  CardTitle,
+  CardAction,
+  CardDescription,
+  CardContent,
+};

+ 357 - 0
examples/chat/components/ui/chart.tsx

@@ -0,0 +1,357 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+  [k in string]: {
+    label?: React.ReactNode;
+    icon?: React.ComponentType;
+  } & (
+    | { color?: string; theme?: never }
+    | { color?: never; theme: Record<keyof typeof THEMES, string> }
+  );
+};
+
+type ChartContextProps = {
+  config: ChartConfig;
+};
+
+const ChartContext = React.createContext<ChartContextProps | null>(null);
+
+function useChart() {
+  const context = React.useContext(ChartContext);
+
+  if (!context) {
+    throw new Error("useChart must be used within a <ChartContainer />");
+  }
+
+  return context;
+}
+
+function ChartContainer({
+  id,
+  className,
+  children,
+  config,
+  ...props
+}: React.ComponentProps<"div"> & {
+  config: ChartConfig;
+  children: React.ComponentProps<
+    typeof RechartsPrimitive.ResponsiveContainer
+  >["children"];
+}) {
+  const uniqueId = React.useId();
+  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+  return (
+    <ChartContext.Provider value={{ config }}>
+      <div
+        data-slot="chart"
+        data-chart={chartId}
+        className={cn(
+          "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
+          className,
+        )}
+        {...props}
+      >
+        <ChartStyle id={chartId} config={config} />
+        <RechartsPrimitive.ResponsiveContainer>
+          {children}
+        </RechartsPrimitive.ResponsiveContainer>
+      </div>
+    </ChartContext.Provider>
+  );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+  const colorConfig = Object.entries(config).filter(
+    ([, config]) => config.theme || config.color,
+  );
+
+  if (!colorConfig.length) {
+    return null;
+  }
+
+  return (
+    <style
+      dangerouslySetInnerHTML={{
+        __html: Object.entries(THEMES)
+          .map(
+            ([theme, prefix]) => `
+${prefix} [data-chart=${id}] {
+${colorConfig
+  .map(([key, itemConfig]) => {
+    const color =
+      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
+      itemConfig.color;
+    return color ? `  --color-${key}: ${color};` : null;
+  })
+  .join("\n")}
+}
+`,
+          )
+          .join("\n"),
+      }}
+    />
+  );
+};
+
+const ChartTooltip = RechartsPrimitive.Tooltip;
+
+function ChartTooltipContent({
+  active,
+  payload,
+  className,
+  indicator = "dot",
+  hideLabel = false,
+  hideIndicator = false,
+  label,
+  labelFormatter,
+  labelClassName,
+  formatter,
+  color,
+  nameKey,
+  labelKey,
+}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
+  React.ComponentProps<"div"> & {
+    hideLabel?: boolean;
+    hideIndicator?: boolean;
+    indicator?: "line" | "dot" | "dashed";
+    nameKey?: string;
+    labelKey?: string;
+  }) {
+  const { config } = useChart();
+
+  const tooltipLabel = React.useMemo(() => {
+    if (hideLabel || !payload?.length) {
+      return null;
+    }
+
+    const [item] = payload;
+    const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
+    const itemConfig = getPayloadConfigFromPayload(config, item, key);
+    const value =
+      !labelKey && typeof label === "string"
+        ? config[label as keyof typeof config]?.label || label
+        : itemConfig?.label;
+
+    if (labelFormatter) {
+      return (
+        <div className={cn("font-medium", labelClassName)}>
+          {labelFormatter(value, payload)}
+        </div>
+      );
+    }
+
+    if (!value) {
+      return null;
+    }
+
+    return <div className={cn("font-medium", labelClassName)}>{value}</div>;
+  }, [
+    label,
+    labelFormatter,
+    payload,
+    hideLabel,
+    labelClassName,
+    config,
+    labelKey,
+  ]);
+
+  if (!active || !payload?.length) {
+    return null;
+  }
+
+  const nestLabel = payload.length === 1 && indicator !== "dot";
+
+  return (
+    <div
+      className={cn(
+        "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
+        className,
+      )}
+    >
+      {!nestLabel ? tooltipLabel : null}
+      <div className="grid gap-1.5">
+        {payload
+          .filter((item) => item.type !== "none")
+          .map((item, index) => {
+            const key = `${nameKey || item.name || item.dataKey || "value"}`;
+            const itemConfig = getPayloadConfigFromPayload(config, item, key);
+            const indicatorColor = color || item.payload.fill || item.color;
+
+            return (
+              <div
+                key={item.dataKey}
+                className={cn(
+                  "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
+                  indicator === "dot" && "items-center",
+                )}
+              >
+                {formatter && item?.value !== undefined && item.name ? (
+                  formatter(item.value, item.name, item, index, item.payload)
+                ) : (
+                  <>
+                    {itemConfig?.icon ? (
+                      <itemConfig.icon />
+                    ) : (
+                      !hideIndicator && (
+                        <div
+                          className={cn(
+                            "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
+                            {
+                              "h-2.5 w-2.5": indicator === "dot",
+                              "w-1": indicator === "line",
+                              "w-0 border-[1.5px] border-dashed bg-transparent":
+                                indicator === "dashed",
+                              "my-0.5": nestLabel && indicator === "dashed",
+                            },
+                          )}
+                          style={
+                            {
+                              "--color-bg": indicatorColor,
+                              "--color-border": indicatorColor,
+                            } as React.CSSProperties
+                          }
+                        />
+                      )
+                    )}
+                    <div
+                      className={cn(
+                        "flex flex-1 justify-between leading-none",
+                        nestLabel ? "items-end" : "items-center",
+                      )}
+                    >
+                      <div className="grid gap-1.5">
+                        {nestLabel ? tooltipLabel : null}
+                        <span className="text-muted-foreground">
+                          {itemConfig?.label || item.name}
+                        </span>
+                      </div>
+                      {item.value && (
+                        <span className="text-foreground font-mono font-medium tabular-nums">
+                          {item.value.toLocaleString()}
+                        </span>
+                      )}
+                    </div>
+                  </>
+                )}
+              </div>
+            );
+          })}
+      </div>
+    </div>
+  );
+}
+
+const ChartLegend = RechartsPrimitive.Legend;
+
+function ChartLegendContent({
+  className,
+  hideIcon = false,
+  payload,
+  verticalAlign = "bottom",
+  nameKey,
+}: React.ComponentProps<"div"> &
+  Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
+    hideIcon?: boolean;
+    nameKey?: string;
+  }) {
+  const { config } = useChart();
+
+  if (!payload?.length) {
+    return null;
+  }
+
+  return (
+    <div
+      className={cn(
+        "flex items-center justify-center gap-4",
+        verticalAlign === "top" ? "pb-3" : "pt-3",
+        className,
+      )}
+    >
+      {payload
+        .filter((item) => item.type !== "none")
+        .map((item) => {
+          const key = `${nameKey || item.dataKey || "value"}`;
+          const itemConfig = getPayloadConfigFromPayload(config, item, key);
+
+          return (
+            <div
+              key={item.value}
+              className={cn(
+                "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
+              )}
+            >
+              {itemConfig?.icon && !hideIcon ? (
+                <itemConfig.icon />
+              ) : (
+                <div
+                  className="h-2 w-2 shrink-0 rounded-[2px]"
+                  style={{
+                    backgroundColor: item.color,
+                  }}
+                />
+              )}
+              {itemConfig?.label}
+            </div>
+          );
+        })}
+    </div>
+  );
+}
+
+// Helper to extract item config from a payload.
+function getPayloadConfigFromPayload(
+  config: ChartConfig,
+  payload: unknown,
+  key: string,
+) {
+  if (typeof payload !== "object" || payload === null) {
+    return undefined;
+  }
+
+  const payloadPayload =
+    "payload" in payload &&
+    typeof payload.payload === "object" &&
+    payload.payload !== null
+      ? payload.payload
+      : undefined;
+
+  let configLabelKey: string = key;
+
+  if (
+    key in payload &&
+    typeof payload[key as keyof typeof payload] === "string"
+  ) {
+    configLabelKey = payload[key as keyof typeof payload] as string;
+  } else if (
+    payloadPayload &&
+    key in payloadPayload &&
+    typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
+  ) {
+    configLabelKey = payloadPayload[
+      key as keyof typeof payloadPayload
+    ] as string;
+  }
+
+  return configLabelKey in config
+    ? config[configLabelKey]
+    : config[key as keyof typeof config];
+}
+
+export {
+  ChartContainer,
+  ChartTooltip,
+  ChartTooltipContent,
+  ChartLegend,
+  ChartLegendContent,
+  ChartStyle,
+};

+ 20 - 0
examples/chat/components/ui/input.tsx

@@ -0,0 +1,20 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef<
+  HTMLInputElement,
+  React.InputHTMLAttributes<HTMLInputElement>
+>(({ className, type, ...props }, ref) => (
+  <input
+    type={type}
+    className={cn(
+      "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+      className,
+    )}
+    ref={ref}
+    {...props}
+  />
+));
+Input.displayName = "Input";
+
+export { Input };

+ 22 - 0
examples/chat/components/ui/label.tsx

@@ -0,0 +1,22 @@
+"use client";
+
+import * as React from "react";
+import { Label as LabelPrimitive } from "radix-ui";
+import { cn } from "@/lib/utils";
+
+const Label = React.forwardRef<
+  React.ComponentRef<typeof LabelPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <LabelPrimitive.Root
+    ref={ref}
+    className={cn(
+      "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+      className,
+    )}
+    {...props}
+  />
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };

+ 31 - 0
examples/chat/components/ui/progress.tsx

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

+ 39 - 0
examples/chat/components/ui/radio-group.tsx

@@ -0,0 +1,39 @@
+"use client";
+
+import * as React from "react";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
+import { Circle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const RadioGroup = React.forwardRef<
+  React.ComponentRef<typeof RadioGroupPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <RadioGroupPrimitive.Root
+    className={cn("grid gap-2", className)}
+    {...props}
+    ref={ref}
+  />
+));
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+  React.ComponentRef<typeof RadioGroupPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => (
+  <RadioGroupPrimitive.Item
+    ref={ref}
+    className={cn(
+      "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+      className,
+    )}
+    {...props}
+  >
+    <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+      <Circle className="h-2.5 w-2.5 fill-current text-current" />
+    </RadioGroupPrimitive.Indicator>
+  </RadioGroupPrimitive.Item>
+));
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };

+ 130 - 0
examples/chat/components/ui/select.tsx

@@ -0,0 +1,130 @@
+"use client";
+
+import * as React from "react";
+import { Select as SelectPrimitive } from "radix-ui";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+const SelectGroup = SelectPrimitive.Group;
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      className,
+    )}
+    {...props}
+  >
+    {children}
+    <SelectPrimitive.Icon asChild>
+      <ChevronDown className="h-4 w-4 opacity-50" />
+    </SelectPrimitive.Icon>
+  </SelectPrimitive.Trigger>
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollUpButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className,
+    )}
+    {...props}
+  >
+    <ChevronUp className="h-4 w-4" />
+  </SelectPrimitive.ScrollUpButton>
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollDownButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className,
+    )}
+    {...props}
+  >
+    <ChevronDown className="h-4 w-4" />
+  </SelectPrimitive.ScrollDownButton>
+));
+SelectScrollDownButton.displayName =
+  SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+  <SelectPrimitive.Portal>
+    <SelectPrimitive.Content
+      ref={ref}
+      className={cn(
+        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
+        position === "popper" &&
+          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+        className,
+      )}
+      position={position}
+      {...props}
+    >
+      <SelectScrollUpButton />
+      <SelectPrimitive.Viewport
+        className={cn(
+          "p-1",
+          position === "popper" &&
+            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
+        )}
+      >
+        {children}
+      </SelectPrimitive.Viewport>
+      <SelectScrollDownButton />
+    </SelectPrimitive.Content>
+  </SelectPrimitive.Portal>
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectItem = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Item
+    ref={ref}
+    className={cn(
+      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      className,
+    )}
+    {...props}
+  >
+    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+      <SelectPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </SelectPrimitive.ItemIndicator>
+    </span>
+    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+  </SelectPrimitive.Item>
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+export {
+  Select,
+  SelectGroup,
+  SelectValue,
+  SelectTrigger,
+  SelectContent,
+  SelectItem,
+  SelectScrollUpButton,
+  SelectScrollDownButton,
+};

+ 28 - 0
examples/chat/components/ui/separator.tsx

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

+ 13 - 0
examples/chat/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 };

+ 116 - 0
examples/chat/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,
+};

+ 91 - 0
examples/chat/components/ui/tabs.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+  className,
+  orientation = "horizontal",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      orientation={orientation}
+      className={cn(
+        "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+const tabsListVariants = cva(
+  "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted",
+        line: "gap-1 bg-transparent",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function TabsList({
+  className,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
+        "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

+ 31 - 0
examples/chat/eslint.config.js

@@ -0,0 +1,31 @@
+import { nextJsConfig } from "@repo/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+      "react/no-unknown-property": [
+        "error",
+        {
+          ignore: [
+            "jsx",
+            // React Three Fiber properties
+            "args",
+            "position",
+            "rotation",
+            "intensity",
+            "distance",
+            "metalness",
+            "roughness",
+            "emissive",
+            "emissiveIntensity",
+            "wireframe",
+            "transparent",
+          ],
+        },
+      ],
+    },
+  },
+];

+ 158 - 0
examples/chat/lib/agent.ts

@@ -0,0 +1,158 @@
+import { ToolLoopAgent, stepCountIs } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { explorerCatalog } from "./render/catalog";
+import { getWeather } from "./tools/weather";
+import { getGitHubRepo, getGitHubPullRequests } from "./tools/github";
+import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto";
+import { getHackerNewsTop } from "./tools/hackernews";
+import { webSearch } from "./tools/search";
+
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content.
+
+WORKFLOW:
+1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools.
+2. Respond with a brief, conversational summary of what you found.
+3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience.
+
+RULES:
+- Always call tools FIRST to get real data. Never make up data.
+- Embed the fetched data directly in /state paths so components can reference it.
+- Use Card components to group related information.
+- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead.
+- Use Grid for multi-column layouts.
+- Use Metric for key numeric values (temperature, stars, price, etc.).
+- Use Table for lists of items (stories, forecasts, languages, etc.).
+- Use BarChart or LineChart for numeric trends and time-series data.
+- Use PieChart for compositional/proportional data (market share, breakdowns, distributions).
+- Use Tabs when showing multiple categories of data side by side.
+- Use Badge for status indicators.
+- Use Callout for key facts, tips, warnings, or important takeaways.
+- Use Accordion to organize detailed sections the user can expand for deeper reading.
+- Use Timeline for historical events, processes, step-by-step explanations, or milestones.
+- When teaching about a topic, combine multiple component types to create a rich, engaging experience.
+
+3D SCENES:
+You can build interactive 3D scenes using React Three Fiber primitives. Use these when the user asks about spatial/visual topics (solar system, molecules, geometry, architecture, physics, etc.).
+
+SCENE STRUCTURE:
+- Scene3D is the root container. ALL other 3D components must be descendants of a Scene3D.
+- Set height (CSS string like "500px"), background color, and cameraPosition [x,y,z].
+- Scene3D includes orbit controls so users can rotate, zoom, and pan the camera.
+
+3D PRIMITIVES:
+- Sphere, Box, Cylinder, Cone, Torus, Plane, Ring — geometry meshes with built-in materials.
+- All accept: position [x,y,z], rotation [x,y,z], scale [x,y,z], color, args (geometry dimensions), metalness, roughness, emissive, emissiveIntensity, wireframe, opacity.
+- args vary per geometry: Sphere [radius, wSeg, hSeg], Box [w, h, d], Cylinder [rTop, rBot, h, seg], Ring [inner, outer, seg], etc.
+- Use emissive + emissiveIntensity for glowing objects (like stars/suns).
+
+GROUPING & ANIMATION:
+- Group3D groups children and applies shared transform + animation.
+- animation: { rotate: [x, y, z] } — continuous rotation speed per frame on each axis.
+- IMPORTANT: Rotation values are applied EVERY FRAME (~60fps). Use very small values! Good orbit speeds are 0.0005 to 0.003. Values above 0.01 look frantic.
+- ORBIT PATTERN: To make an object orbit a center point, put it inside a Group3D with rotation animation. Position the object at its orbital distance from center. The rotating group creates the orbit.
+  Example: Group3D(animation: {rotate: [0, 0.001, 0]}) > Sphere(position: [15, 0, 0]) — the sphere orbits at radius 15.
+- For self-rotation (planet spinning), use animation on the Sphere itself with small values like 0.002-0.005.
+
+LIGHTS:
+- AmbientLight: base illumination for the whole scene (intensity ~0.2-0.5).
+- PointLight: emits from a position in all directions. Use for suns, lamps. Set high intensity (2+) for bright sources.
+- DirectionalLight: parallel rays like sunlight. Position sets direction.
+- Always include at least an AmbientLight so objects are visible.
+
+HELPERS:
+- Stars: starfield background. Use for space scenes. count=5000, fade=true is a good default.
+- Label3D: text in 3D space that always faces the camera. Use to label objects. fontSize ~0.5-1.0 for readable labels.
+- Ring: great for orbit path indicators. Rotate [-1.5708, 0, 0] (i.e. -PI/2) to lay flat, set low opacity (~0.15-0.3).
+
+3D SCENE EXAMPLE (Solar System — all 8 planets):
+Scene3D(height="500px", background="#000010", cameraPosition=[0,30,60]) >
+  Stars(count=5000, fade=true)
+  AmbientLight(intensity=0.2)
+  PointLight(position=[0,0,0], intensity=2)
+  Sphere(args=[2.5,32,32], color="#FDB813", emissive="#FDB813", emissiveIntensity=1) — Sun
+  Group3D(animation={rotate:[0,0.003,0]}) > Sphere(position=[5,0,0], args=[0.3,16,16], color="#8C7853") — Mercury
+  Group3D(animation={rotate:[0,0.002,0]}) > Sphere(position=[8,0,0], args=[0.7,16,16], color="#FFC649") — Venus
+  Group3D(animation={rotate:[0,0.0015,0]}) > [Sphere(position=[12,0,0], args=[0.8,16,16], color="#4B7BE5"), Group3D(position=[12,0,0], animation={rotate:[0,0.008,0]}) > Sphere(position=[1.5,0,0], args=[0.2,12,12], color="#CCC")] — Earth + Moon
+  Group3D(animation={rotate:[0,0.001,0]}) > Sphere(position=[16,0,0], args=[0.5,16,16], color="#E27B58") — Mars
+  Group3D(animation={rotate:[0,0.0005,0]}) > Sphere(position=[22,0,0], args=[2,20,20], color="#C88B3A") — Jupiter
+  Group3D(animation={rotate:[0,0.0003,0]}) > Sphere(position=[28,0,0], args=[1.7,20,20], color="#FAD5A5") — Saturn
+  Group3D(animation={rotate:[0,0.0002,0]}) > Sphere(position=[34,0,0], args=[1.2,16,16], color="#ACE5EE") — Uranus
+  Group3D(animation={rotate:[0,0.00015,0]}) > Sphere(position=[40,0,0], args=[1.1,16,16], color="#5B5EA6") — Neptune
+  Ring(rotation=[-1.5708,0,0], args=[inner,outer,64], color="#ffffff", opacity=0.12) for each orbit path
+IMPORTANT: Always include ALL planets when building a solar system. Do not truncate to just 4.
+
+MIXING 2D AND 3D:
+- You can combine 3D scenes with regular 2D components in the same spec. For example, use a Stack or Card at the root with a Scene3D plus Text, Callout, Accordion, etc. as siblings. This lets you build a rich educational experience with both an interactive 3D visualization and text content.
+
+DATA BINDING:
+- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop.
+- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props.
+- Scalar binding: "title": { "$state": "/quiz/title" }
+- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.)
+- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state.
+- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders.
+- Always use the { "$state": "/foo" } object syntax for data binding.
+
+INTERACTIVITY:
+- You can use visible, repeat, on.press, and $cond/$then/$else freely.
+- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" }
+- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" }
+- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }
+- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" }
+
+BUILT-IN ACTIONS (use with on.press):
+- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" }
+- pushState: Append to an array. params: { statePath: "/items", value: { ... } }
+- removeState: Remove by index. params: { statePath: "/items", index: 0 }
+
+INPUT COMPONENTS:
+- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically.
+- SelectInput: Dropdown select. Writes selected value to statePath automatically.
+- TextInput: Text input field. Writes entered value to statePath automatically.
+- Button: Clickable button. Use on.press to trigger actions.
+
+PATTERN — INTERACTIVE QUIZZES:
+When the user asks for a quiz, test, or Q&A, build an interactive experience:
+1. Initialize state for each question's answer and submission status:
+   {"op":"add","path":"/state/q1","value":""}
+   {"op":"add","path":"/state/q1_submitted","value":false}
+2. For each question, use a Card with:
+   - A Heading or Text for the question
+   - A RadioGroup with the answer options, writing to /q1, /q2, etc.
+   - A Button with on.press to set the submitted flag: {"action":"setState","params":{"statePath":"/q1_submitted","value":true}}
+   - A Text (or Callout) showing feedback, using visible to show only after submission:
+     "visible": [{"$state":"/q1_submitted","eq":true},{"$state":"/q1","eq":"correct_value"}]
+   - Show correct/incorrect feedback using separate visible conditions on different elements.
+3. Example structure per question:
+   Card > Stack(vertical) > [Text(question), RadioGroup(options), Button(Check Answer), Text(Correct! visible when right), Callout(Wrong, visible when wrong & submitted)]
+4. You can also add a final score section that becomes visible when all questions are submitted.
+
+${explorerCatalog.prompt({
+  mode: "chat",
+  customRules: [
+    "NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
+    "Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
+    "Use Metric components for key numbers instead of plain Text.",
+    "Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.",
+    "Keep the UI clean and information-dense — no excessive padding or empty space.",
+    "For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.",
+  ],
+})}`;
+
+export const agent = new ToolLoopAgent({
+  model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
+  instructions: AGENT_INSTRUCTIONS,
+  tools: {
+    getWeather,
+    getGitHubRepo,
+    getGitHubPullRequests,
+    getCryptoPrice,
+    getCryptoPriceHistory,
+    getHackerNewsTop,
+    webSearch,
+  },
+  stopWhen: stepCountIs(5),
+  temperature: 0.7,
+});

+ 589 - 0
examples/chat/lib/render/catalog.ts

@@ -0,0 +1,589 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { z } from "zod";
+
+// =============================================================================
+// Shared 3D schemas
+// =============================================================================
+
+const vec3 = z.array(z.number());
+
+const animation3D = z
+  .object({
+    rotate: vec3.nullable(),
+  })
+  .nullable();
+
+const transform3DProps = {
+  position: vec3.nullable(),
+  rotation: vec3.nullable(),
+  scale: vec3.nullable(),
+};
+
+const material3DProps = {
+  color: z.string().nullable(),
+  metalness: z.number().nullable(),
+  roughness: z.number().nullable(),
+  emissive: z.string().nullable(),
+  emissiveIntensity: z.number().nullable(),
+  wireframe: z.boolean().nullable(),
+  opacity: z.number().nullable(),
+};
+
+const mesh3DProps = {
+  ...transform3DProps,
+  ...material3DProps,
+  args: z.array(z.number()).nullable(),
+  animation: animation3D,
+};
+
+/**
+ * json-render + AI SDK Example Catalog
+ *
+ * Components for rendering data dashboards generated by the ToolLoopAgent.
+ * Data flows in through tools (weather, GitHub, crypto, HN), not user actions.
+ */
+export const explorerCatalog = defineCatalog(schema, {
+  components: {
+    // Layout
+    Stack: {
+      props: z.object({
+        direction: z.enum(["horizontal", "vertical"]).nullable(),
+        gap: z.enum(["sm", "md", "lg"]).nullable(),
+        wrap: z.boolean().nullable(),
+      }),
+      slots: ["default"],
+      description: "Flex layout container",
+      example: { direction: "vertical", gap: "md", wrap: null },
+    },
+
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+      }),
+      slots: ["default"],
+      description: "Card container with optional title and description",
+      example: { title: "Weather", description: "Current conditions" },
+    },
+
+    Grid: {
+      props: z.object({
+        columns: z.enum(["1", "2", "3", "4"]).nullable(),
+        gap: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Responsive grid layout container",
+      example: { columns: "3", gap: "md" },
+    },
+
+    // Typography
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
+      }),
+      description: "Section heading",
+      example: { text: "Data Explorer", level: "h1" },
+    },
+
+    Text: {
+      props: z.object({
+        content: z.string(),
+        muted: z.boolean().nullable(),
+      }),
+      description: "Text content",
+      example: { content: "Here is your data overview." },
+    },
+
+    // Data display
+    Badge: {
+      props: z.object({
+        text: z.string(),
+        variant: z
+          .enum(["default", "secondary", "destructive", "outline"])
+          .nullable(),
+      }),
+      description: "Status badge",
+      example: { text: "Live", variant: "default" },
+    },
+
+    Alert: {
+      props: z.object({
+        variant: z.enum(["default", "destructive"]).nullable(),
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      description: "Alert or info message",
+    },
+
+    Separator: {
+      props: z.object({}),
+      description: "Visual divider",
+    },
+
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        detail: z.string().nullable(),
+        trend: z.enum(["up", "down", "neutral"]).nullable(),
+      }),
+      description:
+        "Single metric display with label, value, and optional trend indicator",
+      example: {
+        label: "Temperature",
+        value: "72F",
+        detail: "Feels like 68F",
+        trend: "up",
+      },
+    },
+
+    Table: {
+      props: z.object({
+        data: z.array(z.record(z.string(), z.unknown())),
+        columns: z.array(
+          z.object({
+            key: z.string(),
+            label: z.string(),
+          }),
+        ),
+        emptyMessage: z.string().nullable(),
+      }),
+      description:
+        'Data table. Use { "$state": "/path" } to bind read-only data from state.',
+      example: {
+        data: { $state: "/stories" },
+        columns: [
+          { key: "title", label: "Title" },
+          { key: "score", label: "Score" },
+        ],
+      },
+    },
+
+    Link: {
+      props: z.object({
+        text: z.string(),
+        href: z.string(),
+      }),
+      description: "External link that opens in a new tab",
+      example: { text: "View on GitHub", href: "https://github.com" },
+    },
+
+    // Charts
+    BarChart: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(z.record(z.string(), z.unknown())),
+        xKey: z.string(),
+        yKey: z.string(),
+        aggregate: z.enum(["sum", "count", "avg"]).nullable(),
+        color: z.string().nullable(),
+        height: z.number().nullable(),
+      }),
+      description:
+        'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.',
+    },
+
+    LineChart: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(z.record(z.string(), z.unknown())),
+        xKey: z.string(),
+        yKey: z.string(),
+        aggregate: z.enum(["sum", "count", "avg"]).nullable(),
+        color: z.string().nullable(),
+        height: z.number().nullable(),
+      }),
+      description:
+        'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.',
+    },
+
+    // Interactive
+    Tabs: {
+      props: z.object({
+        defaultValue: z.string().nullable(),
+        tabs: z.array(
+          z.object({
+            value: z.string(),
+            label: z.string(),
+          }),
+        ),
+      }),
+      slots: ["default"],
+      description: "Tabbed content container",
+    },
+
+    TabContent: {
+      props: z.object({
+        value: z.string(),
+      }),
+      slots: ["default"],
+      description: "Content for a specific tab",
+    },
+
+    Progress: {
+      props: z.object({
+        value: z.number(),
+        max: z.number().nullable(),
+      }),
+      description: "Progress bar",
+    },
+
+    Skeleton: {
+      props: z.object({
+        width: z.string().nullable(),
+        height: z.string().nullable(),
+      }),
+      description: "Loading placeholder",
+    },
+
+    // Educational / Rich content
+    Callout: {
+      props: z.object({
+        type: z.enum(["info", "tip", "warning", "important"]).nullable(),
+        title: z.string().nullable(),
+        content: z.string(),
+      }),
+      description:
+        "Highlighted callout box for tips, warnings, notes, or key information",
+      example: {
+        type: "tip",
+        title: "Did you know?",
+        content: "The sun is about 93 million miles from Earth.",
+      },
+    },
+
+    Accordion: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string(),
+            content: z.string(),
+          }),
+        ),
+        type: z.enum(["single", "multiple"]).nullable(),
+      }),
+      description:
+        "Collapsible accordion sections for organizing detailed content",
+      example: {
+        items: [{ title: "Overview", content: "A brief introduction." }],
+        type: "multiple",
+      },
+    },
+
+    Timeline: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string(),
+            description: z.string().nullable(),
+            date: z.string().nullable(),
+            status: z.enum(["completed", "current", "upcoming"]).nullable(),
+          }),
+        ),
+      }),
+      description:
+        "Vertical timeline showing ordered events, steps, or historical milestones",
+      example: {
+        items: [
+          {
+            title: "Discovery",
+            description: "Initial breakthrough",
+            date: "1905",
+            status: "completed",
+          },
+        ],
+      },
+    },
+
+    PieChart: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(z.record(z.string(), z.unknown())),
+        nameKey: z.string(),
+        valueKey: z.string(),
+        height: z.number().nullable(),
+      }),
+      description:
+        'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.',
+    },
+
+    // Interactive / Input
+    RadioGroup: {
+      props: z.object({
+        label: z.string().nullable(),
+        value: z.string().nullable(),
+        options: z.array(
+          z.object({
+            value: z.string(),
+            label: z.string(),
+          }),
+        ),
+      }),
+      description:
+        'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding. Use for multiple-choice questions, settings, or any single-select input.',
+      example: {
+        label: "Choose one",
+        value: { $bindState: "/answer" },
+        options: [
+          { value: "a", label: "Option A" },
+          { value: "b", label: "Option B" },
+        ],
+      },
+    },
+
+    SelectInput: {
+      props: z.object({
+        label: z.string().nullable(),
+        value: z.string().nullable(),
+        placeholder: z.string().nullable(),
+        options: z.array(
+          z.object({
+            value: z.string(),
+            label: z.string(),
+          }),
+        ),
+      }),
+      description:
+        'Dropdown select input. Use { "$bindState": "/path" } for two-way binding. Use when there are many options and a dropdown is more compact than radio buttons.',
+      example: {
+        label: "Country",
+        value: { $bindState: "/selectedCountry" },
+        placeholder: "Select a country",
+        options: [
+          { value: "us", label: "United States" },
+          { value: "uk", label: "United Kingdom" },
+        ],
+      },
+    },
+
+    TextInput: {
+      props: z.object({
+        label: z.string().nullable(),
+        value: z.string().nullable(),
+        placeholder: z.string().nullable(),
+        type: z.enum(["text", "email", "number", "password", "url"]).nullable(),
+      }),
+      description:
+        'Text input field. Use { "$bindState": "/path" } for two-way binding. Use for free-text entry like names, emails, search, etc.',
+      example: {
+        label: "Your name",
+        value: { $bindState: "/userName" },
+        placeholder: "Enter your name",
+        type: "text",
+      },
+    },
+
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z
+          .enum(["default", "secondary", "destructive", "outline", "ghost"])
+          .nullable(),
+        size: z.enum(["default", "sm", "lg"]).nullable(),
+        disabled: z.boolean().nullable(),
+      }),
+      description:
+        "Clickable button. Use with on.press to trigger actions like setState, pushState, etc. Can be used for quiz submissions, form actions, navigation, and more.",
+      example: {
+        label: "Submit",
+        variant: "default",
+        size: "default",
+        disabled: null,
+      },
+    },
+
+    // =========================================================================
+    // 3D Scene Components (React Three Fiber)
+    // =========================================================================
+
+    // Containers
+    Scene3D: {
+      props: z.object({
+        height: z.string().nullable(),
+        background: z.string().nullable(),
+        cameraPosition: vec3.nullable(),
+        cameraFov: z.number().nullable(),
+        autoRotate: z.boolean().nullable(),
+      }),
+      slots: ["default"],
+      description:
+        "3D scene container with orbit controls. All 3D components (Sphere, Box, lights, etc.) must be children of a Scene3D. height is a CSS value like '500px'.",
+      example: {
+        height: "500px",
+        background: "#000010",
+        cameraPosition: [0, 25, 45],
+        cameraFov: null,
+        autoRotate: null,
+      },
+    },
+
+    Group3D: {
+      props: z.object({
+        ...transform3DProps,
+        animation: animation3D,
+      }),
+      slots: ["default"],
+      description:
+        "3D group for positioning, rotating, and animating children together. Use to create orbits: position a planet inside a Group3D and animate the group's rotation.",
+      example: {
+        position: null,
+        rotation: null,
+        scale: null,
+        animation: { rotate: [0, 0.005, 0] },
+      },
+    },
+
+    // Geometry primitives
+    Box: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D box/cube mesh. args: [width, height, depth]. Supports on.press for click interaction.",
+      example: {
+        position: [0, 0, 0],
+        color: "#4488ff",
+        args: [1, 1, 1],
+      },
+    },
+
+    Sphere: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D sphere mesh. args: [radius, widthSegments, heightSegments]. Use higher segment counts (32+) for smooth spheres.",
+      example: {
+        position: [0, 0, 0],
+        color: "#4B7BE5",
+        args: [1, 32, 32],
+      },
+    },
+
+    Cylinder: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D cylinder mesh. args: [radiusTop, radiusBottom, height, radialSegments].",
+      example: {
+        position: [0, 0, 0],
+        color: "#88aa44",
+        args: [1, 1, 2, 32],
+      },
+    },
+
+    Cone: {
+      props: z.object(mesh3DProps),
+      description: "3D cone mesh. args: [radius, height, radialSegments].",
+      example: {
+        position: [0, 0, 0],
+        color: "#ff8844",
+        args: [1, 2, 32],
+      },
+    },
+
+    Torus: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D torus (donut) mesh. args: [radius, tube, radialSegments, tubularSegments].",
+      example: {
+        position: [0, 0, 0],
+        color: "#aa44ff",
+        args: [1, 0.4, 16, 100],
+      },
+    },
+
+    Plane: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D flat plane mesh. args: [width, height]. Useful for ground planes or flat surfaces.",
+      example: {
+        position: [0, -1, 0],
+        rotation: [-Math.PI / 2, 0, 0],
+        color: "#334455",
+        args: [10, 10],
+      },
+    },
+
+    Ring: {
+      props: z.object(mesh3DProps),
+      description:
+        "3D flat ring mesh. args: [innerRadius, outerRadius, thetaSegments]. Great for orbit path indicators.",
+      example: {
+        position: [0, 0, 0],
+        rotation: [-Math.PI / 2, 0, 0],
+        color: "#ffffff",
+        opacity: 0.2,
+        args: [14.8, 15.2, 64],
+      },
+    },
+
+    // Lights
+    AmbientLight: {
+      props: z.object({
+        color: z.string().nullable(),
+        intensity: z.number().nullable(),
+      }),
+      description:
+        "Ambient light that illuminates all objects equally. Use for base scene illumination.",
+      example: { color: null, intensity: 0.3 },
+    },
+
+    PointLight: {
+      props: z.object({
+        position: vec3.nullable(),
+        color: z.string().nullable(),
+        intensity: z.number().nullable(),
+        distance: z.number().nullable(),
+      }),
+      description:
+        "Point light that emits from a position in all directions. Use for suns, lamps, etc.",
+      example: { position: [0, 0, 0], intensity: 2 },
+    },
+
+    DirectionalLight: {
+      props: z.object({
+        position: vec3.nullable(),
+        color: z.string().nullable(),
+        intensity: z.number().nullable(),
+      }),
+      description:
+        "Directional light like sunlight. Position sets direction, not location.",
+      example: { position: [5, 10, 5], intensity: 1 },
+    },
+
+    // Helpers (drei)
+    Stars: {
+      props: z.object({
+        radius: z.number().nullable(),
+        depth: z.number().nullable(),
+        count: z.number().nullable(),
+        factor: z.number().nullable(),
+        fade: z.boolean().nullable(),
+        speed: z.number().nullable(),
+      }),
+      description:
+        "Starfield background for space scenes. Renders thousands of tiny points around the scene.",
+      example: { count: 5000, fade: true },
+    },
+
+    Label3D: {
+      props: z.object({
+        text: z.string(),
+        position: vec3.nullable(),
+        rotation: vec3.nullable(),
+        color: z.string().nullable(),
+        fontSize: z.number().nullable(),
+        anchorX: z.enum(["left", "center", "right"]).nullable(),
+        anchorY: z.enum(["top", "middle", "bottom"]).nullable(),
+      }),
+      description:
+        "Text label rendered in 3D space. Always faces the camera (billboard). Use for labeling objects in a scene.",
+      example: {
+        text: "Earth",
+        position: [15, 2, 0],
+        color: "#ffffff",
+        fontSize: 0.8,
+      },
+    },
+  },
+
+  actions: {},
+});

+ 1072 - 0
examples/chat/lib/render/registry.tsx

@@ -0,0 +1,1072 @@
+"use client";
+
+import { useState, useRef, type ReactNode } from "react";
+import { useBoundProp, defineRegistry } from "@json-render/react";
+import {
+  Bar,
+  BarChart as RechartsBarChart,
+  CartesianGrid,
+  Legend,
+  Line,
+  LineChart as RechartsLineChart,
+  Pie,
+  PieChart as RechartsPieChart,
+  XAxis,
+} from "recharts";
+import {
+  ChartContainer,
+  ChartTooltip,
+  ChartTooltipContent,
+  type ChartConfig,
+} from "@/components/ui/chart";
+
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  CardDescription,
+  CardContent,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { Separator } from "@/components/ui/separator";
+import {
+  Table,
+  TableHeader,
+  TableBody,
+  TableHead,
+  TableRow,
+  TableCell,
+} from "@/components/ui/table";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import {
+  Accordion as AccordionRoot,
+  AccordionItem,
+  AccordionTrigger,
+  AccordionContent,
+} from "@/components/ui/accordion";
+import { Progress } from "@/components/ui/progress";
+import { Skeleton } from "@/components/ui/skeleton";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+  TrendingUp,
+  TrendingDown,
+  Minus,
+  Info,
+  Lightbulb,
+  AlertTriangle,
+  Star,
+  ArrowUpDown,
+  ArrowUp,
+  ArrowDown,
+} from "lucide-react";
+
+// 3D imports
+import { Canvas, useFrame } from "@react-three/fiber";
+import {
+  OrbitControls,
+  Stars as DreiStars,
+  Text as DreiText,
+} from "@react-three/drei";
+import type * as THREE from "three";
+
+import { explorerCatalog } from "./catalog";
+
+// =============================================================================
+// 3D Helper Types & Components
+// =============================================================================
+
+type Vec3Tuple = [number, number, number];
+
+interface Animation3D {
+  rotate?: number[] | null;
+}
+
+interface Mesh3DProps {
+  position?: number[] | null;
+  rotation?: number[] | null;
+  scale?: number[] | null;
+  color?: string | null;
+  args?: number[] | null;
+  metalness?: number | null;
+  roughness?: number | null;
+  emissive?: string | null;
+  emissiveIntensity?: number | null;
+  wireframe?: boolean | null;
+  opacity?: number | null;
+  animation?: Animation3D | null;
+}
+
+function toVec3(v: number[] | null | undefined): Vec3Tuple | undefined {
+  if (!v || v.length < 3) return undefined;
+  return v.slice(0, 3) as Vec3Tuple;
+}
+
+function toGeoArgs<T extends unknown[]>(
+  v: number[] | null | undefined,
+  fallback: T,
+): T {
+  if (!v || v.length === 0) return fallback;
+  return v as unknown as T;
+}
+
+/** Shared hook for continuous rotation animation */
+function useRotationAnimation(
+  ref: React.RefObject<THREE.Object3D | null>,
+  animation?: Animation3D | null,
+) {
+  useFrame(() => {
+    if (!ref.current || !animation?.rotate) return;
+    const [rx, ry, rz] = animation.rotate;
+    ref.current.rotation.x += rx ?? 0;
+    ref.current.rotation.y += ry ?? 0;
+    ref.current.rotation.z += rz ?? 0;
+  });
+}
+
+/** Standard material props shared by all mesh primitives */
+function StandardMaterial({
+  color,
+  metalness,
+  roughness,
+  emissive,
+  emissiveIntensity,
+  wireframe,
+  opacity,
+}: Mesh3DProps) {
+  return (
+    <meshStandardMaterial
+      color={color ?? "#cccccc"}
+      metalness={metalness ?? 0.1}
+      roughness={roughness ?? 0.8}
+      emissive={emissive ?? undefined}
+      emissiveIntensity={emissiveIntensity ?? 1}
+      wireframe={wireframe ?? false}
+      transparent={opacity != null && opacity < 1}
+      opacity={opacity ?? 1}
+    />
+  );
+}
+
+/** Generic mesh wrapper for all geometry primitives */
+function MeshPrimitive({
+  meshProps,
+  children,
+  onClick,
+}: {
+  meshProps: Mesh3DProps;
+  children: ReactNode;
+  onClick?: () => void;
+}) {
+  const ref = useRef<THREE.Mesh>(null);
+  useRotationAnimation(ref, meshProps.animation);
+  return (
+    <mesh
+      ref={ref}
+      position={toVec3(meshProps.position)}
+      rotation={toVec3(meshProps.rotation)}
+      scale={toVec3(meshProps.scale)}
+      onClick={onClick}
+    >
+      {children}
+      <StandardMaterial {...meshProps} />
+    </mesh>
+  );
+}
+
+/** Animated group wrapper */
+function AnimatedGroup({
+  position,
+  rotation,
+  scale,
+  animation,
+  children,
+}: {
+  position?: number[] | null;
+  rotation?: number[] | null;
+  scale?: number[] | null;
+  animation?: Animation3D | null;
+  children?: ReactNode;
+}) {
+  const ref = useRef<THREE.Group>(null);
+  useRotationAnimation(ref, animation);
+  return (
+    <group
+      ref={ref}
+      position={toVec3(position)}
+      rotation={toVec3(rotation)}
+      scale={toVec3(scale)}
+    >
+      {children}
+    </group>
+  );
+}
+
+// =============================================================================
+// Registry
+// =============================================================================
+
+export const { registry, handlers } = defineRegistry(explorerCatalog, {
+  components: {
+    Stack: ({ props, children }) => {
+      const gapClass =
+        { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
+      return (
+        <div
+          className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
+        >
+          {children}
+        </div>
+      );
+    },
+
+    Card: ({ props, children }) => (
+      <Card>
+        {(props.title || props.description) && (
+          <CardHeader>
+            {props.title && <CardTitle>{props.title}</CardTitle>}
+            {props.description && (
+              <CardDescription>{props.description}</CardDescription>
+            )}
+          </CardHeader>
+        )}
+        <CardContent className="flex flex-col gap-4">{children}</CardContent>
+      </Card>
+    ),
+
+    Grid: ({ props, children }) => {
+      const colsClass =
+        {
+          "1": "grid-cols-1",
+          "2": "grid-cols-1 md:grid-cols-2",
+          "3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
+          "4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
+        }[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
+      const gapClass =
+        { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
+      return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
+    },
+
+    Heading: ({ props }) => {
+      const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
+      const sizeClass = {
+        h1: "text-3xl font-bold tracking-tight",
+        h2: "text-2xl font-semibold tracking-tight",
+        h3: "text-xl font-semibold",
+        h4: "text-lg font-medium",
+      }[props.level ?? "h2"];
+      return <Tag className={sizeClass}>{props.text}</Tag>;
+    },
+
+    Text: ({ props }) => (
+      <p className={props.muted ? "text-muted-foreground" : ""}>
+        {props.content}
+      </p>
+    ),
+
+    Badge: ({ props }) => (
+      <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
+    ),
+
+    Alert: ({ props }) => (
+      <Alert variant={props.variant ?? "default"}>
+        <AlertTitle>{props.title}</AlertTitle>
+        {props.description ? (
+          <AlertDescription>{props.description}</AlertDescription>
+        ) : null}
+      </Alert>
+    ),
+
+    Separator: () => <Separator />,
+
+    Metric: ({ props }) => {
+      const TrendIcon =
+        props.trend === "up"
+          ? TrendingUp
+          : props.trend === "down"
+            ? TrendingDown
+            : Minus;
+      const trendColor =
+        props.trend === "up"
+          ? "text-green-500"
+          : props.trend === "down"
+            ? "text-red-500"
+            : "text-muted-foreground";
+      return (
+        <div className="flex flex-col gap-1">
+          <p className="text-sm text-muted-foreground">{props.label}</p>
+          <div className="flex items-center gap-2">
+            <span className="text-2xl font-bold">{props.value}</span>
+            {props.trend && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
+          </div>
+          {props.detail && (
+            <p className="text-xs text-muted-foreground">{props.detail}</p>
+          )}
+        </div>
+      );
+    },
+
+    Table: ({ props }) => {
+      const rawData = props.data;
+      const items: Array<Record<string, unknown>> = Array.isArray(rawData)
+        ? rawData
+        : Array.isArray((rawData as Record<string, unknown>)?.data)
+          ? ((rawData as Record<string, unknown>).data as Array<
+              Record<string, unknown>
+            >)
+          : [];
+
+      const [sortKey, setSortKey] = useState<string | null>(null);
+      const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+
+      if (items.length === 0) {
+        return (
+          <div className="text-center py-4 text-muted-foreground">
+            {props.emptyMessage ?? "No data"}
+          </div>
+        );
+      }
+
+      const sorted = sortKey
+        ? [...items].sort((a, b) => {
+            const av = a[sortKey];
+            const bv = b[sortKey];
+            // numeric comparison when both values are numbers
+            if (typeof av === "number" && typeof bv === "number") {
+              return sortDir === "asc" ? av - bv : bv - av;
+            }
+            const as = String(av ?? "");
+            const bs = String(bv ?? "");
+            return sortDir === "asc"
+              ? as.localeCompare(bs)
+              : bs.localeCompare(as);
+          })
+        : items;
+
+      const handleSort = (key: string) => {
+        if (sortKey === key) {
+          setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+        } else {
+          setSortKey(key);
+          setSortDir("asc");
+        }
+      };
+
+      return (
+        <Table>
+          <TableHeader>
+            <TableRow>
+              {props.columns.map((col) => {
+                const SortIcon =
+                  sortKey === col.key
+                    ? sortDir === "asc"
+                      ? ArrowUp
+                      : ArrowDown
+                    : ArrowUpDown;
+                return (
+                  <TableHead key={col.key}>
+                    <button
+                      type="button"
+                      className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
+                      onClick={() => handleSort(col.key)}
+                    >
+                      {col.label}
+                      <SortIcon className="h-3 w-3 text-muted-foreground" />
+                    </button>
+                  </TableHead>
+                );
+              })}
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {sorted.map((item, i) => (
+              <TableRow key={i}>
+                {props.columns.map((col) => (
+                  <TableCell key={col.key}>
+                    {String(item[col.key] ?? "")}
+                  </TableCell>
+                ))}
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      );
+    },
+
+    Link: ({ props }) => (
+      <a
+        href={props.href}
+        target="_blank"
+        rel="noopener noreferrer"
+        className="text-primary underline underline-offset-4 hover:text-primary/80"
+      >
+        {props.text}
+      </a>
+    ),
+
+    BarChart: ({ props }) => {
+      const rawData = props.data;
+      const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
+        ? rawData
+        : Array.isArray((rawData as Record<string, unknown>)?.data)
+          ? ((rawData as Record<string, unknown>).data as Array<
+              Record<string, unknown>
+            >)
+          : [];
+
+      const { items, valueKey } = processChartData(
+        rawItems,
+        props.xKey,
+        props.yKey,
+        props.aggregate,
+      );
+
+      const chartColor = props.color ?? "var(--chart-1)";
+
+      const chartConfig = {
+        [valueKey]: {
+          label: valueKey,
+          color: chartColor,
+        },
+      } satisfies ChartConfig;
+
+      if (items.length === 0) {
+        return (
+          <div className="text-center py-4 text-muted-foreground">
+            No data available
+          </div>
+        );
+      }
+
+      return (
+        <div className="w-full">
+          {props.title && (
+            <p className="text-sm font-medium mb-2">{props.title}</p>
+          )}
+          <ChartContainer
+            config={chartConfig}
+            className="min-h-[200px] w-full"
+            style={{ height: props.height ?? 300 }}
+          >
+            <RechartsBarChart accessibilityLayer data={items}>
+              <CartesianGrid vertical={false} />
+              <XAxis
+                dataKey="label"
+                tickLine={false}
+                tickMargin={10}
+                axisLine={false}
+              />
+              <ChartTooltip content={<ChartTooltipContent />} />
+              <Bar
+                dataKey={valueKey}
+                fill={`var(--color-${valueKey})`}
+                radius={4}
+              />
+            </RechartsBarChart>
+          </ChartContainer>
+        </div>
+      );
+    },
+
+    LineChart: ({ props }) => {
+      const rawData = props.data;
+      const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
+        ? rawData
+        : Array.isArray((rawData as Record<string, unknown>)?.data)
+          ? ((rawData as Record<string, unknown>).data as Array<
+              Record<string, unknown>
+            >)
+          : [];
+
+      const { items, valueKey } = processChartData(
+        rawItems,
+        props.xKey,
+        props.yKey,
+        props.aggregate,
+      );
+
+      const chartColor = props.color ?? "var(--chart-1)";
+
+      const chartConfig = {
+        [valueKey]: {
+          label: valueKey,
+          color: chartColor,
+        },
+      } satisfies ChartConfig;
+
+      if (items.length === 0) {
+        return (
+          <div className="text-center py-4 text-muted-foreground">
+            No data available
+          </div>
+        );
+      }
+
+      return (
+        <div className="w-full">
+          {props.title && (
+            <p className="text-sm font-medium mb-2">{props.title}</p>
+          )}
+          <ChartContainer
+            config={chartConfig}
+            className="min-h-[200px] w-full [&_svg]:overflow-visible"
+            style={{ height: props.height ?? 300 }}
+          >
+            <RechartsLineChart accessibilityLayer data={items}>
+              <CartesianGrid vertical={false} />
+              <XAxis
+                dataKey="label"
+                tickLine={false}
+                tickMargin={10}
+                axisLine={false}
+                interval={
+                  items.length > 12
+                    ? Math.ceil(items.length / 8) - 1
+                    : undefined
+                }
+              />
+              <ChartTooltip content={<ChartTooltipContent />} />
+              <Line
+                type="monotone"
+                dataKey={valueKey}
+                stroke={`var(--color-${valueKey})`}
+                strokeWidth={2}
+                dot={false}
+              />
+            </RechartsLineChart>
+          </ChartContainer>
+        </div>
+      );
+    },
+
+    Tabs: ({ props, children }) => (
+      <Tabs defaultValue={props.defaultValue ?? (props.tabs ?? [])[0]?.value}>
+        <TabsList>
+          {(props.tabs ?? []).map((tab) => (
+            <TabsTrigger key={tab.value} value={tab.value}>
+              {tab.label}
+            </TabsTrigger>
+          ))}
+        </TabsList>
+        {children}
+      </Tabs>
+    ),
+
+    TabContent: ({ props, children }) => (
+      <TabsContent value={props.value}>{children}</TabsContent>
+    ),
+
+    Progress: ({ props }) => (
+      <Progress value={props.value} max={props.max ?? 100} />
+    ),
+
+    Skeleton: ({ props }) => (
+      <Skeleton
+        className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
+      />
+    ),
+
+    Callout: ({ props }) => {
+      const config = {
+        info: {
+          icon: Info,
+          border: "border-l-blue-500",
+          bg: "bg-blue-500/5",
+          iconColor: "text-blue-500",
+        },
+        tip: {
+          icon: Lightbulb,
+          border: "border-l-emerald-500",
+          bg: "bg-emerald-500/5",
+          iconColor: "text-emerald-500",
+        },
+        warning: {
+          icon: AlertTriangle,
+          border: "border-l-amber-500",
+          bg: "bg-amber-500/5",
+          iconColor: "text-amber-500",
+        },
+        important: {
+          icon: Star,
+          border: "border-l-purple-500",
+          bg: "bg-purple-500/5",
+          iconColor: "text-purple-500",
+        },
+      }[props.type ?? "info"] ?? {
+        icon: Info,
+        border: "border-l-blue-500",
+        bg: "bg-blue-500/5",
+        iconColor: "text-blue-500",
+      };
+      const Icon = config.icon;
+      return (
+        <div
+          className={`border-l-4 ${config.border} ${config.bg} rounded-r-lg p-4`}
+        >
+          <div className="flex items-start gap-3">
+            <Icon className={`h-5 w-5 mt-0.5 shrink-0 ${config.iconColor}`} />
+            <div className="flex-1 min-w-0">
+              {props.title && (
+                <p className="font-semibold text-sm mb-1">{props.title}</p>
+              )}
+              <p className="text-sm text-muted-foreground">{props.content}</p>
+            </div>
+          </div>
+        </div>
+      );
+    },
+
+    Accordion: ({ props }) => (
+      <AccordionRoot
+        type={props.type === "single" ? "single" : "multiple"}
+        collapsible={props.type === "single" ? true : undefined}
+        className="w-full"
+      >
+        {(props.items ?? []).map((item, i) => (
+          <AccordionItem key={i} value={`item-${i}`}>
+            <AccordionTrigger>{item.title}</AccordionTrigger>
+            <AccordionContent>
+              <p className="text-muted-foreground">{item.content}</p>
+            </AccordionContent>
+          </AccordionItem>
+        ))}
+      </AccordionRoot>
+    ),
+
+    Timeline: ({ props }) => (
+      <div className="relative pl-8">
+        {/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
+        <div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
+        <div className="flex flex-col gap-6">
+          {(props.items ?? []).map((item, i) => {
+            const dotColor =
+              item.status === "completed"
+                ? "bg-emerald-500"
+                : item.status === "current"
+                  ? "bg-blue-500"
+                  : "bg-muted-foreground/30";
+            return (
+              <div key={i} className="relative">
+                <div
+                  className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
+                />
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2 flex-wrap">
+                    <p className="font-medium text-sm">{item.title}</p>
+                    {item.date && (
+                      <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
+                        {item.date}
+                      </span>
+                    )}
+                  </div>
+                  {item.description && (
+                    <p className="text-sm text-muted-foreground mt-1">
+                      {item.description}
+                    </p>
+                  )}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    ),
+
+    PieChart: ({ props }) => {
+      const rawData = props.data;
+      const items: Array<Record<string, unknown>> = Array.isArray(rawData)
+        ? rawData
+        : Array.isArray((rawData as Record<string, unknown>)?.data)
+          ? ((rawData as Record<string, unknown>).data as Array<
+              Record<string, unknown>
+            >)
+          : [];
+
+      if (items.length === 0) {
+        return (
+          <div className="text-center py-4 text-muted-foreground">
+            No data available
+          </div>
+        );
+      }
+
+      const chartConfig: ChartConfig = {};
+      items.forEach((item, i) => {
+        const name = String(item[props.nameKey] ?? `Segment ${i + 1}`);
+        chartConfig[name] = {
+          label: name,
+          color: PIE_COLORS[i % PIE_COLORS.length],
+        };
+      });
+
+      return (
+        <div className="w-full">
+          {props.title && (
+            <p className="text-sm font-medium mb-2">{props.title}</p>
+          )}
+          <ChartContainer
+            config={chartConfig}
+            className="mx-auto aspect-square w-full"
+            style={{ height: props.height ?? 300 }}
+          >
+            <RechartsPieChart>
+              <ChartTooltip content={<ChartTooltipContent />} />
+              <Pie
+                data={items.map((item, i) => ({
+                  name: String(item[props.nameKey] ?? `Segment ${i + 1}`),
+                  value:
+                    typeof item[props.valueKey] === "number"
+                      ? item[props.valueKey]
+                      : parseFloat(String(item[props.valueKey])) || 0,
+                  fill: PIE_COLORS[i % PIE_COLORS.length],
+                }))}
+                dataKey="value"
+                nameKey="name"
+                innerRadius="40%"
+                outerRadius="70%"
+                paddingAngle={2}
+              />
+              <Legend />
+            </RechartsPieChart>
+          </ChartContainer>
+        </div>
+      );
+    },
+
+    RadioGroup: ({ props, bindings }) => {
+      const [value, setValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
+      const current = value ?? "";
+
+      return (
+        <div className="flex flex-col gap-2">
+          {props.label && (
+            <Label className="text-sm font-medium">{props.label}</Label>
+          )}
+          <RadioGroup
+            value={current}
+            onValueChange={(v: string) => setValue(v)}
+          >
+            {(props.options ?? []).map((opt) => (
+              <div key={opt.value} className="flex items-center gap-2">
+                <RadioGroupItem value={opt.value} id={`rg-${opt.value}`} />
+                <Label
+                  htmlFor={`rg-${opt.value}`}
+                  className="font-normal cursor-pointer"
+                >
+                  {opt.label}
+                </Label>
+              </div>
+            ))}
+          </RadioGroup>
+        </div>
+      );
+    },
+
+    SelectInput: ({ props, bindings }) => {
+      const [value, setValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
+      const current = value ?? "";
+
+      return (
+        <div className="flex flex-col gap-2">
+          {props.label && (
+            <Label className="text-sm font-medium">{props.label}</Label>
+          )}
+          <Select value={current} onValueChange={(v: string) => setValue(v)}>
+            <SelectTrigger>
+              <SelectValue placeholder={props.placeholder ?? "Select..."} />
+            </SelectTrigger>
+            <SelectContent>
+              {(props.options ?? []).map((opt) => (
+                <SelectItem key={opt.value} value={opt.value}>
+                  {opt.label}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+      );
+    },
+
+    TextInput: ({ props, bindings }) => {
+      const [value, setValue] = useBoundProp<string>(
+        props.value as string | undefined,
+        bindings?.value,
+      );
+      const current = value ?? "";
+
+      return (
+        <div className="flex flex-col gap-2">
+          {props.label && (
+            <Label className="text-sm font-medium">{props.label}</Label>
+          )}
+          <Input
+            type={props.type ?? "text"}
+            placeholder={props.placeholder ?? ""}
+            value={current}
+            onChange={(e) => setValue(e.target.value)}
+          />
+        </div>
+      );
+    },
+
+    Button: ({ props, emit }) => (
+      <Button
+        variant={props.variant ?? "default"}
+        size={props.size ?? "default"}
+        disabled={props.disabled ?? false}
+        onClick={() => emit("press")}
+      >
+        {props.label}
+      </Button>
+    ),
+
+    // =========================================================================
+    // 3D Scene Components
+    // =========================================================================
+
+    Scene3D: ({ props, children }) => (
+      <div
+        style={{
+          height: props.height ?? "400px",
+          width: "100%",
+          background: props.background ?? "#111111",
+          borderRadius: 8,
+          overflow: "hidden",
+        }}
+      >
+        <Canvas
+          camera={{
+            position: toVec3(props.cameraPosition) ?? [0, 10, 30],
+            fov: props.cameraFov ?? 50,
+          }}
+        >
+          <OrbitControls
+            autoRotate={props.autoRotate ?? false}
+            enablePan
+            enableZoom
+          />
+          {children}
+        </Canvas>
+      </div>
+    ),
+
+    Group3D: ({ props, children }) => (
+      <AnimatedGroup
+        position={props.position}
+        rotation={props.rotation}
+        scale={props.scale}
+        animation={props.animation}
+      >
+        {children}
+      </AnimatedGroup>
+    ),
+
+    Box: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <boxGeometry
+          args={toGeoArgs<[number, number, number]>(props.args, [1, 1, 1])}
+        />
+      </MeshPrimitive>
+    ),
+
+    Sphere: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <sphereGeometry
+          args={toGeoArgs<[number, number, number]>(props.args, [1, 32, 32])}
+        />
+      </MeshPrimitive>
+    ),
+
+    Cylinder: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <cylinderGeometry
+          args={toGeoArgs<[number, number, number, number]>(
+            props.args,
+            [1, 1, 2, 32],
+          )}
+        />
+      </MeshPrimitive>
+    ),
+
+    Cone: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <coneGeometry
+          args={toGeoArgs<[number, number, number]>(props.args, [1, 2, 32])}
+        />
+      </MeshPrimitive>
+    ),
+
+    Torus: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <torusGeometry
+          args={toGeoArgs<[number, number, number, number]>(
+            props.args,
+            [1, 0.4, 16, 100],
+          )}
+        />
+      </MeshPrimitive>
+    ),
+
+    Plane: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <planeGeometry
+          args={toGeoArgs<[number, number]>(props.args, [10, 10])}
+        />
+      </MeshPrimitive>
+    ),
+
+    Ring: ({ props, emit }) => (
+      <MeshPrimitive meshProps={props} onClick={() => emit("press")}>
+        <ringGeometry
+          args={toGeoArgs<[number, number, number]>(props.args, [0.5, 1, 64])}
+        />
+      </MeshPrimitive>
+    ),
+
+    AmbientLight: ({ props }) => (
+      <ambientLight
+        color={props.color ?? undefined}
+        intensity={props.intensity ?? 0.5}
+      />
+    ),
+
+    PointLight: ({ props }) => (
+      <pointLight
+        position={toVec3(props.position)}
+        color={props.color ?? undefined}
+        intensity={props.intensity ?? 1}
+        distance={props.distance ?? 0}
+      />
+    ),
+
+    DirectionalLight: ({ props }) => (
+      <directionalLight
+        position={toVec3(props.position)}
+        color={props.color ?? undefined}
+        intensity={props.intensity ?? 1}
+      />
+    ),
+
+    Stars: ({ props }) => (
+      <DreiStars
+        radius={props.radius ?? 100}
+        depth={props.depth ?? 50}
+        count={props.count ?? 5000}
+        factor={props.factor ?? 4}
+        fade={props.fade ?? true}
+        speed={props.speed ?? 1}
+      />
+    ),
+
+    Label3D: ({ props }) => (
+      <DreiText
+        position={toVec3(props.position)}
+        rotation={toVec3(props.rotation)}
+        color={props.color ?? "#ffffff"}
+        fontSize={props.fontSize ?? 1}
+        anchorX={props.anchorX ?? "center"}
+        anchorY={props.anchorY ?? "middle"}
+      >
+        {props.text}
+      </DreiText>
+    ),
+  },
+
+  actions: {},
+});
+
+// =============================================================================
+// Chart Helpers
+// =============================================================================
+
+const PIE_COLORS = [
+  "var(--chart-1)",
+  "var(--chart-2)",
+  "var(--chart-3)",
+  "var(--chart-4)",
+  "var(--chart-5)",
+];
+
+function processChartData(
+  items: Array<Record<string, unknown>>,
+  xKey: string,
+  yKey: string,
+  aggregate: "sum" | "count" | "avg" | null | undefined,
+): { items: Array<Record<string, unknown>>; valueKey: string } {
+  if (items.length === 0) {
+    return { items: [], valueKey: yKey };
+  }
+
+  if (!aggregate) {
+    const formatted = items.map((item) => ({
+      ...item,
+      label: String(item[xKey] ?? ""),
+    }));
+    return { items: formatted, valueKey: yKey };
+  }
+
+  const groups = new Map<string, Array<Record<string, unknown>>>();
+
+  for (const item of items) {
+    const groupKey = String(item[xKey] ?? "unknown");
+    const group = groups.get(groupKey) ?? [];
+    group.push(item);
+    groups.set(groupKey, group);
+  }
+
+  const valueKey = aggregate === "count" ? "count" : yKey;
+  const aggregated: Array<Record<string, unknown>> = [];
+  const sortedKeys = Array.from(groups.keys()).sort();
+
+  for (const key of sortedKeys) {
+    const group = groups.get(key)!;
+    let value: number;
+
+    if (aggregate === "count") {
+      value = group.length;
+    } else if (aggregate === "sum") {
+      value = group.reduce((sum, item) => {
+        const v = item[yKey];
+        return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
+      }, 0);
+    } else {
+      const sum = group.reduce((s, item) => {
+        const v = item[yKey];
+        return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
+      }, 0);
+      value = group.length > 0 ? sum / group.length : 0;
+    }
+
+    aggregated.push({ label: key, [valueKey]: value });
+  }
+
+  return { items: aggregated, valueKey };
+}
+
+// =============================================================================
+// Fallback Component
+// =============================================================================
+
+export function Fallback({ type }: { type: string }) {
+  return (
+    <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
+      Unknown component: {type}
+    </div>
+  );
+}

+ 48 - 0
examples/chat/lib/render/renderer.tsx

@@ -0,0 +1,48 @@
+"use client";
+
+import { type ReactNode } from "react";
+import {
+  Renderer,
+  type ComponentRenderer,
+  type Spec,
+  StateProvider,
+  VisibilityProvider,
+  ActionProvider,
+} from "@json-render/react";
+
+import { registry, Fallback } from "./registry";
+
+// =============================================================================
+// ExplorerRenderer
+// =============================================================================
+
+interface ExplorerRendererProps {
+  spec: Spec | null;
+  loading?: boolean;
+}
+
+const fallback: ComponentRenderer = ({ element }) => (
+  <Fallback type={element.type} />
+);
+
+export function ExplorerRenderer({
+  spec,
+  loading,
+}: ExplorerRendererProps): ReactNode {
+  if (!spec) return null;
+
+  return (
+    <StateProvider initialState={spec.state ?? {}}>
+      <VisibilityProvider>
+        <ActionProvider>
+          <Renderer
+            spec={spec}
+            registry={registry}
+            fallback={fallback}
+            loading={loading}
+          />
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}

+ 165 - 0
examples/chat/lib/tools/crypto.ts

@@ -0,0 +1,165 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+// =============================================================================
+// Helpers
+// =============================================================================
+
+function handleFetchError(res: Response, coinId: string) {
+  if (res.status === 404) {
+    return { error: `Cryptocurrency not found: ${coinId}` };
+  }
+  if (res.status === 429) {
+    return { error: "CoinGecko rate limit exceeded. Try again in a minute." };
+  }
+  return { error: `Failed to fetch crypto data: ${res.statusText}` };
+}
+
+function sampleTimeSeries(
+  prices: [number, number][],
+  maxPoints: number,
+): Array<{ date: string; price: number }> {
+  const step = Math.max(1, Math.floor(prices.length / maxPoints));
+  return prices
+    .filter((_, i) => i % step === 0)
+    .map(([timestamp, price]) => ({
+      date: new Date(timestamp).toLocaleDateString("en-US", {
+        month: "short",
+        day: "numeric",
+      }),
+      price: Math.round(price * 100) / 100,
+    }));
+}
+
+// =============================================================================
+// getCryptoPrice — current market data + 7-day sparkline
+// =============================================================================
+
+/**
+ * Get cryptocurrency market data from CoinGecko.
+ * Free public API, no API key required.
+ * https://docs.coingecko.com/reference/introduction
+ */
+export const getCryptoPrice = tool({
+  description:
+    "Get current price, market cap, 24h change, and 7-day sparkline for a cryptocurrency. For longer price history (30d, 90d, 365d), use getCryptoPriceHistory instead.",
+  inputSchema: z.object({
+    coinId: z
+      .string()
+      .describe(
+        "CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin', 'cardano')",
+      ),
+  }),
+  execute: async ({ coinId }) => {
+    const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=true`;
+
+    const res = await fetch(url, {
+      headers: { Accept: "application/json" },
+    });
+
+    if (!res.ok) return handleFetchError(res, coinId);
+
+    const data = (await res.json()) as {
+      id: string;
+      symbol: string;
+      name: string;
+      market_data: {
+        current_price: { usd: number };
+        market_cap: { usd: number };
+        total_volume: { usd: number };
+        price_change_percentage_24h: number;
+        price_change_percentage_7d: number;
+        price_change_percentage_30d: number;
+        high_24h: { usd: number };
+        low_24h: { usd: number };
+        ath: { usd: number };
+        ath_date: { usd: string };
+        circulating_supply: number;
+        total_supply: number | null;
+        sparkline_7d: { price: number[] };
+      };
+      market_cap_rank: number;
+    };
+
+    const md = data.market_data;
+
+    // Convert sparkline (hourly array) to dated points
+    const now = Date.now();
+    const sparkline = md.sparkline_7d.price;
+    const step = Math.max(1, Math.floor(sparkline.length / 14));
+    const sparklineData = sparkline
+      .filter((_, i) => i % step === 0)
+      .map((price, i) => {
+        const hourIndex = i * step;
+        const ts = now - (sparkline.length - hourIndex) * 3600_000;
+        return {
+          date: new Date(ts).toLocaleDateString("en-US", {
+            month: "short",
+            day: "numeric",
+          }),
+          price: Math.round(price * 100) / 100,
+        };
+      });
+
+    return {
+      id: data.id,
+      symbol: data.symbol.toUpperCase(),
+      name: data.name,
+      rank: data.market_cap_rank,
+      price: md.current_price.usd,
+      marketCap: md.market_cap.usd,
+      volume24h: md.total_volume.usd,
+      change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
+      change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
+      change30d: Math.round(md.price_change_percentage_30d * 100) / 100,
+      high24h: md.high_24h.usd,
+      low24h: md.low_24h.usd,
+      allTimeHigh: md.ath.usd,
+      allTimeHighDate: md.ath_date.usd,
+      circulatingSupply: md.circulating_supply,
+      totalSupply: md.total_supply,
+      sparkline7d: sparklineData,
+    };
+  },
+});
+
+// =============================================================================
+// getCryptoPriceHistory — flexible date range price history
+// =============================================================================
+
+export const getCryptoPriceHistory = tool({
+  description:
+    "Get historical price data for a cryptocurrency over a specified number of days (e.g., 30, 90, 365). Returns date-labeled data points suitable for charting.",
+  inputSchema: z.object({
+    coinId: z
+      .string()
+      .describe("CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana')"),
+    days: z
+      .number()
+      .int()
+      .min(1)
+      .max(365)
+      .describe("Number of days of history to fetch (e.g., 30, 90, 365)"),
+  }),
+  execute: async ({ coinId, days }) => {
+    const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}/market_chart?vs_currency=usd&days=${days}`;
+
+    const res = await fetch(url, {
+      headers: { Accept: "application/json" },
+    });
+
+    if (!res.ok) return handleFetchError(res, coinId);
+
+    const data = (await res.json()) as {
+      prices: [number, number][];
+    };
+
+    const priceHistory = sampleTimeSeries(data.prices, 20);
+
+    return {
+      coinId,
+      days,
+      priceHistory,
+    };
+  },
+});

+ 237 - 0
examples/chat/lib/tools/github.ts

@@ -0,0 +1,237 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+// ---------------------------------------------------------------------------
+// Shared helpers
+// ---------------------------------------------------------------------------
+
+const ghHeaders = { Accept: "application/vnd.github.v3+json" };
+
+function handleGitHubError(res: Response, context: string) {
+  if (res.status === 404) return { error: `Not found: ${context}` };
+  if (res.status === 403)
+    return { error: "GitHub API rate limit exceeded. Try again later." };
+  return { error: `Failed to fetch ${context}: ${res.statusText}` };
+}
+
+// ---------------------------------------------------------------------------
+// getGitHubRepo
+// ---------------------------------------------------------------------------
+
+/**
+ * Get public GitHub repository information.
+ * Uses the public GitHub REST API (no auth, 60 req/hr rate limit).
+ */
+export const getGitHubRepo = tool({
+  description:
+    "Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.",
+  inputSchema: z.object({
+    owner: z.string().describe("Repository owner (e.g., 'vercel')"),
+    repo: z.string().describe("Repository name (e.g., 'next.js')"),
+  }),
+  execute: async ({ owner, repo }) => {
+    const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
+
+    const [repoRes, languagesRes] = await Promise.all([
+      fetch(repoUrl, { headers: ghHeaders }),
+      fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
+    ]);
+
+    if (!repoRes.ok) {
+      return handleGitHubError(repoRes, `${owner}/${repo}`);
+    }
+
+    const repoData = (await repoRes.json()) as {
+      full_name: string;
+      description: string | null;
+      html_url: string;
+      stargazers_count: number;
+      forks_count: number;
+      open_issues_count: number;
+      watchers_count: number;
+      language: string | null;
+      license: { spdx_id: string } | null;
+      created_at: string;
+      updated_at: string;
+      pushed_at: string;
+      topics: string[];
+      size: number;
+      default_branch: string;
+      archived: boolean;
+      fork: boolean;
+    };
+
+    const languages: Record<string, number> = languagesRes.ok
+      ? ((await languagesRes.json()) as Record<string, number>)
+      : {};
+
+    const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
+    const languageBreakdown = Object.entries(languages)
+      .map(([lang, bytes]) => ({
+        language: lang,
+        percentage: Math.round((bytes / totalBytes) * 100),
+        bytes,
+      }))
+      .sort((a, b) => b.bytes - a.bytes)
+      .slice(0, 8);
+
+    return {
+      name: repoData.full_name,
+      description: repoData.description,
+      url: repoData.html_url,
+      stars: repoData.stargazers_count,
+      forks: repoData.forks_count,
+      openIssues: repoData.open_issues_count,
+      watchers: repoData.watchers_count,
+      primaryLanguage: repoData.language,
+      license: repoData.license?.spdx_id ?? "None",
+      createdAt: repoData.created_at,
+      updatedAt: repoData.updated_at,
+      lastPush: repoData.pushed_at,
+      topics: repoData.topics,
+      defaultBranch: repoData.default_branch,
+      archived: repoData.archived,
+      isFork: repoData.fork,
+      languages: languageBreakdown,
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// getGitHubPullRequests
+// ---------------------------------------------------------------------------
+
+type GitHubPR = {
+  number: number;
+  title: string;
+  state: string;
+  html_url: string;
+  user: { login: string } | null;
+  created_at: string;
+  updated_at: string;
+  merged_at: string | null;
+  comments: number;
+  labels: Array<{ name: string }>;
+  draft: boolean;
+};
+
+type GitHubPRReview = {
+  id: number;
+};
+
+type GitHubPRReaction = {
+  total_count: number;
+};
+
+/**
+ * Get pull requests from a public GitHub repository.
+ * Supports filtering by state and sorting by various criteria.
+ * Fetches comment counts and reactions for ranking "most popular" PRs.
+ */
+export const getGitHubPullRequests = tool({
+  description:
+    "Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.",
+  inputSchema: z.object({
+    owner: z.string().describe("Repository owner (e.g., 'vercel')"),
+    repo: z.string().describe("Repository name (e.g., 'next.js')"),
+    state: z
+      .enum(["open", "closed", "all"])
+      .nullable()
+      .describe("Filter by state. Defaults to 'open'."),
+    sort: z
+      .enum(["created", "updated", "popularity", "long-running"])
+      .nullable()
+      .describe(
+        "Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.",
+      ),
+    perPage: z
+      .number()
+      .int()
+      .min(1)
+      .max(30)
+      .nullable()
+      .describe("Number of PRs to return (1-30). Defaults to 10."),
+  }),
+  execute: async ({ owner, repo, state, sort, perPage }) => {
+    const count = perPage ?? 10;
+    const prState = state ?? "open";
+
+    // GitHub API sort param: 'popularity' and 'long-running' are API-native
+    const apiSort =
+      sort === "popularity"
+        ? "popularity"
+        : sort === "long-running"
+          ? "long-running"
+          : sort === "updated"
+            ? "updated"
+            : "created";
+
+    const url = new URL(
+      `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
+    );
+    url.searchParams.set("state", prState);
+    url.searchParams.set("sort", apiSort);
+    url.searchParams.set("direction", "desc");
+    url.searchParams.set("per_page", String(count));
+
+    const res = await fetch(url.toString(), { headers: ghHeaders });
+
+    if (!res.ok) {
+      return handleGitHubError(res, `${owner}/${repo} pull requests`);
+    }
+
+    const prs = (await res.json()) as GitHubPR[];
+
+    // Fetch review + reaction counts in parallel for richer data
+    const enriched = await Promise.all(
+      prs.map(async (pr) => {
+        const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`;
+
+        const [reviewsRes, reactionsRes] = await Promise.all([
+          fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }),
+          fetch(
+            `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`,
+            {
+              headers: {
+                ...ghHeaders,
+                Accept: "application/vnd.github.squirrel-girl-preview+json",
+              },
+            },
+          ),
+        ]);
+
+        const reviews: GitHubPRReview[] = reviewsRes.ok
+          ? ((await reviewsRes.json()) as GitHubPRReview[])
+          : [];
+
+        let reactionCount = 0;
+        if (reactionsRes.ok) {
+          const reactions = (await reactionsRes.json()) as GitHubPRReaction[];
+          reactionCount = reactions.length;
+        }
+
+        return {
+          number: pr.number,
+          title: pr.title,
+          state: pr.merged_at ? "merged" : pr.state,
+          author: pr.user?.login ?? "unknown",
+          url: pr.html_url,
+          createdAt: pr.created_at,
+          updatedAt: pr.updated_at,
+          comments: pr.comments,
+          reviews: reviews.length,
+          reactions: reactionCount,
+          labels: pr.labels.map((l) => l.name),
+          draft: pr.draft,
+        };
+      }),
+    );
+
+    return {
+      repository: `${owner}/${repo}`,
+      state: prState,
+      count: enriched.length,
+      pullRequests: enriched,
+    };
+  },
+});

+ 67 - 0
examples/chat/lib/tools/hackernews.ts

@@ -0,0 +1,67 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+/**
+ * Get top stories from Hacker News.
+ * Uses the official HN Firebase API. Free, no auth required.
+ * https://github.com/HackerNewsAPI/API
+ */
+export const getHackerNewsTop = tool({
+  description:
+    "Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
+  inputSchema: z.object({
+    count: z
+      .number()
+      .min(1)
+      .max(30)
+      .describe("Number of top stories to fetch (1-30)"),
+  }),
+  execute: async ({ count }) => {
+    const topUrl =
+      "https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty";
+    const topRes = await fetch(topUrl);
+
+    if (!topRes.ok) {
+      return { error: "Failed to fetch Hacker News top stories" };
+    }
+
+    const topIds = (await topRes.json()) as number[];
+    const storyIds = topIds.slice(0, count);
+
+    const stories = await Promise.all(
+      storyIds.map(async (id) => {
+        const storyRes = await fetch(
+          `https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
+        );
+        if (!storyRes.ok) return null;
+
+        const story = (await storyRes.json()) as {
+          id: number;
+          title: string;
+          url?: string;
+          score: number;
+          by: string;
+          time: number;
+          descendants?: number;
+          type: string;
+        };
+
+        return {
+          id: story.id,
+          title: story.title,
+          url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
+          score: story.score,
+          author: story.by,
+          comments: story.descendants ?? 0,
+          postedAt: new Date(story.time * 1000).toISOString(),
+          hnUrl: `https://news.ycombinator.com/item?id=${story.id}`,
+        };
+      }),
+    );
+
+    return {
+      stories: stories.filter(Boolean),
+      fetchedAt: new Date().toISOString(),
+    };
+  },
+});

+ 36 - 0
examples/chat/lib/tools/search.ts

@@ -0,0 +1,36 @@
+import { tool, generateText } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { z } from "zod";
+
+/**
+ * Web search tool using Perplexity Sonar via AI Gateway.
+ *
+ * Perplexity Sonar models have built-in internet access and return
+ * synthesized answers with citations. This is wrapped as a regular tool
+ * (with an `execute` function) so that ToolLoopAgent can loop: it calls
+ * the model, gets results, and feeds them back for the next step.
+ */
+export const webSearch = tool({
+  description:
+    "Search the web for current information on any topic. Use this when the user asks about something not covered by the specialized tools (weather, crypto, GitHub, Hacker News). Returns a synthesized answer based on real-time web data.",
+  inputSchema: z.object({
+    query: z
+      .string()
+      .describe(
+        "The search query — be specific and include relevant context for better results",
+      ),
+  }),
+  execute: async ({ query }) => {
+    try {
+      const { text } = await generateText({
+        model: gateway("perplexity/sonar"),
+        prompt: query,
+      });
+      return { content: text };
+    } catch (error) {
+      return {
+        error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
+      };
+    }
+  },
+});

+ 126 - 0
examples/chat/lib/tools/weather.ts

@@ -0,0 +1,126 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+/**
+ * Get current weather and 7-day forecast for a city using Open-Meteo API.
+ * Free, no API key required.
+ * https://open-meteo.com/
+ */
+export const getWeather = tool({
+  description:
+    "Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
+  inputSchema: z.object({
+    city: z
+      .string()
+      .describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
+  }),
+  execute: async ({ city }) => {
+    // Step 1: Geocode the city name to coordinates
+    const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
+    const geocodeRes = await fetch(geocodeUrl);
+
+    if (!geocodeRes.ok) {
+      return { error: `Failed to geocode city: ${city}` };
+    }
+
+    const geocodeData = (await geocodeRes.json()) as {
+      results?: Array<{
+        name: string;
+        country: string;
+        latitude: number;
+        longitude: number;
+        timezone: string;
+      }>;
+    };
+
+    if (!geocodeData.results || geocodeData.results.length === 0) {
+      return { error: `City not found: ${city}` };
+    }
+
+    const location = geocodeData.results[0]!;
+
+    // Step 2: Get weather data
+    const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
+
+    const weatherRes = await fetch(weatherUrl);
+
+    if (!weatherRes.ok) {
+      return { error: "Failed to fetch weather data" };
+    }
+
+    const weather = (await weatherRes.json()) as {
+      current: {
+        temperature_2m: number;
+        relative_humidity_2m: number;
+        apparent_temperature: number;
+        weather_code: number;
+        wind_speed_10m: number;
+      };
+      daily: {
+        time: string[];
+        weather_code: number[];
+        temperature_2m_max: number[];
+        temperature_2m_min: number[];
+        precipitation_sum: number[];
+      };
+    };
+
+    const weatherDescription = describeWeatherCode(
+      weather.current.weather_code,
+    );
+
+    const forecast = weather.daily.time.map((date, i) => ({
+      date,
+      day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
+        weekday: "short",
+      }),
+      high: Math.round(weather.daily.temperature_2m_max[i]!),
+      low: Math.round(weather.daily.temperature_2m_min[i]!),
+      condition: describeWeatherCode(weather.daily.weather_code[i]!),
+      precipitation: weather.daily.precipitation_sum[i]!,
+    }));
+
+    return {
+      city: location.name,
+      country: location.country,
+      current: {
+        temperature: Math.round(weather.current.temperature_2m),
+        feelsLike: Math.round(weather.current.apparent_temperature),
+        humidity: weather.current.relative_humidity_2m,
+        windSpeed: Math.round(weather.current.wind_speed_10m),
+        condition: weatherDescription,
+      },
+      forecast,
+    };
+  },
+});
+
+function describeWeatherCode(code: number): string {
+  const descriptions: Record<number, string> = {
+    0: "Clear sky",
+    1: "Mainly clear",
+    2: "Partly cloudy",
+    3: "Overcast",
+    45: "Foggy",
+    48: "Depositing rime fog",
+    51: "Light drizzle",
+    53: "Moderate drizzle",
+    55: "Dense drizzle",
+    61: "Slight rain",
+    63: "Moderate rain",
+    65: "Heavy rain",
+    71: "Slight snow",
+    73: "Moderate snow",
+    75: "Heavy snow",
+    77: "Snow grains",
+    80: "Slight rain showers",
+    81: "Moderate rain showers",
+    82: "Violent rain showers",
+    85: "Slight snow showers",
+    86: "Heavy snow showers",
+    95: "Thunderstorm",
+    96: "Thunderstorm with slight hail",
+    99: "Thunderstorm with heavy hail",
+  };
+  return descriptions[code] ?? "Unknown";
+}

+ 6 - 0
examples/chat/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 6 - 0
examples/chat/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 5 - 0
examples/chat/next.config.ts

@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+export default nextConfig;

+ 50 - 0
examples/chat/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "example-chat",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.13",
+    "@ai-sdk/react": "^3.0.84",
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@react-three/drei": "^10.7.7",
+    "@react-three/fiber": "^9.5.0",
+    "@streamdown/code": "^1.0.2",
+    "ai": "^6.0.33",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.563.0",
+    "next": "16.1.6",
+    "next-themes": "^0.4.6",
+    "radix-ui": "^1.4.3",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "recharts": "^2.15.4",
+    "sonner": "^2.0.7",
+    "streamdown": "^2.2.0",
+    "tailwind-merge": "^3.4.0",
+    "three": "^0.182.0",
+    "zod": "4.3.5"
+  },
+  "devDependencies": {
+    "@repo/eslint-config": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "@types/three": "^0.182.0",
+    "eslint": "^9.39.1",
+    "postcss": "^8.5.6",
+    "tailwindcss": "^4.1.18",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 5 - 0
examples/chat/postcss.config.mjs

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};

+ 13 - 0
examples/chat/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 1 - 1
examples/dashboard/app/api/generate/route.ts

@@ -13,7 +13,7 @@ export async function POST(req: Request) {
 
   const userPrompt = buildUserPrompt({
     prompt,
-    state: context?.data,
+    state: context?.state,
   });
 
   const result = streamText({

+ 6 - 2
examples/dashboard/app/api/v1/widgets/route.ts

@@ -1,8 +1,12 @@
 import { getWidgets, createWidget } from "@/lib/db/store";
 
 export async function GET() {
-  const widgetList = await getWidgets();
-  return Response.json({ data: widgetList, total: widgetList.length });
+  try {
+    const widgetList = await getWidgets();
+    return Response.json({ data: widgetList, total: widgetList.length });
+  } catch {
+    return Response.json({ data: [], total: 0 });
+  }
 }
 
 export async function POST(req: Request) {

+ 2 - 1
examples/dashboard/app/page.tsx

@@ -38,6 +38,7 @@ function DashboardContent() {
     async function loadWidgets() {
       try {
         const res = await fetch("/api/v1/widgets");
+        if (!res.ok) throw new Error(`HTTP ${res.status}`);
         const data = await res.json();
         setSavedWidgets(data.data || []);
       } catch (err) {
@@ -53,7 +54,7 @@ function DashboardContent() {
   const handleWidgetSaved = useCallback((_id: string) => {
     // Reload saved widgets to get the new one
     fetch("/api/v1/widgets")
-      .then((res) => res.json())
+      .then((res) => (res.ok ? res.json() : { data: [] }))
       .then((data) => {
         setSavedWidgets(data.data || []);
         // Remove the new widget slot since it's now saved

+ 27 - 20
examples/dashboard/lib/render/catalog.ts

@@ -57,14 +57,15 @@ export const dashboardCatalog = defineCatalog(schema, {
     Input: {
       props: z.object({
         label: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         placeholder: z.string().nullable(),
         type: z.enum(["text", "email", "password", "number", "tel"]).nullable(),
       }),
-      description: "Text input field",
+      description:
+        "Text input field. Use value with $bindState for two-way binding",
       example: {
         label: "Email",
-        valuePath: "/form/email",
+        value: { $bindState: "/form/email" },
         placeholder: "you@example.com",
         type: "email",
       },
@@ -118,10 +119,11 @@ export const dashboardCatalog = defineCatalog(schema, {
     Checkbox: {
       props: z.object({
         label: z.string().nullable(),
-        valuePath: z.string(),
+        checked: z.boolean().nullable(),
         defaultChecked: z.boolean().nullable(),
       }),
-      description: "Checkbox input",
+      description:
+        "Checkbox input. Use checked with $bindState for two-way binding",
     },
 
     Dialog: {
@@ -194,7 +196,7 @@ export const dashboardCatalog = defineCatalog(schema, {
 
     RadioGroup: {
       props: z.object({
-        valuePath: z.string(),
+        value: z.string().nullable(),
         options: z.array(
           z.object({
             value: z.string(),
@@ -203,12 +205,13 @@ export const dashboardCatalog = defineCatalog(schema, {
         ),
         defaultValue: z.string().nullable(),
       }),
-      description: "Radio button group",
+      description:
+        "Radio button group. Use value with $bindState for two-way binding",
     },
 
     Select: {
       props: z.object({
-        valuePath: z.string(),
+        value: z.string().nullable(),
         placeholder: z.string().nullable(),
         options: z.array(
           z.object({
@@ -217,7 +220,8 @@ export const dashboardCatalog = defineCatalog(schema, {
           }),
         ),
       }),
-      description: "Dropdown select input",
+      description:
+        "Dropdown select input. Use value with $bindState for two-way binding",
     },
 
     Skeleton: {
@@ -238,10 +242,11 @@ export const dashboardCatalog = defineCatalog(schema, {
     Switch: {
       props: z.object({
         label: z.string().nullable(),
-        valuePath: z.string(),
+        checked: z.boolean().nullable(),
         defaultChecked: z.boolean().nullable(),
       }),
-      description: "Toggle switch",
+      description:
+        "Toggle switch. Use checked with $bindState for two-way binding",
     },
 
     Tabs: {
@@ -269,11 +274,12 @@ export const dashboardCatalog = defineCatalog(schema, {
     Textarea: {
       props: z.object({
         label: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         placeholder: z.string().nullable(),
         rows: z.number().nullable(),
       }),
-      description: "Multi-line text input",
+      description:
+        "Multi-line text input. Use value with $bindState for two-way binding",
     },
 
     Tooltip: {
@@ -286,7 +292,7 @@ export const dashboardCatalog = defineCatalog(schema, {
 
     Table: {
       props: z.object({
-        statePath: z.string(),
+        data: z.array(z.record(z.string(), z.unknown())),
         columns: z.array(
           z.object({
             key: z.string(),
@@ -312,9 +318,10 @@ export const dashboardCatalog = defineCatalog(schema, {
           .nullable(),
         emptyMessage: z.string().nullable(),
       }),
-      description: "Data table with optional row actions (delete, edit, etc.)",
+      description:
+        "Data table with optional row actions. Use { $state } on data to bind to an array of objects.",
       example: {
-        statePath: "/customers/data",
+        data: { $state: "/customers/data" },
         columns: [
           { key: "name", label: "Name" },
           { key: "email", label: "Email" },
@@ -345,7 +352,7 @@ export const dashboardCatalog = defineCatalog(schema, {
     BarChart: {
       props: z.object({
         title: z.string().nullable(),
-        statePath: z.string(),
+        data: z.array(z.record(z.string(), z.unknown())),
         xKey: z.string(),
         yKey: z.string(),
         aggregate: z.enum(["sum", "count", "avg"]).nullable(),
@@ -353,13 +360,13 @@ export const dashboardCatalog = defineCatalog(schema, {
         height: z.number().nullable(),
       }),
       description:
-        "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.",
+        "Bar chart visualization. Use { $state } on data to point 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(),
-        statePath: z.string(),
+        data: z.array(z.record(z.string(), z.unknown())),
         xKey: z.string(),
         yKey: z.string(),
         aggregate: z.enum(["sum", "count", "avg"]).nullable(),
@@ -367,7 +374,7 @@ export const dashboardCatalog = defineCatalog(schema, {
         height: z.number().nullable(),
       }),
       description:
-        "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.",
+        "Line chart visualization. Use { $state } on data to point 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.",
     },
   },
 

+ 58 - 55
examples/dashboard/lib/render/registry.tsx

@@ -1,8 +1,8 @@
 "use client";
 
 import { toast } from "sonner";
-import { findFormValue, getByPath } from "@json-render/core";
-import { useStateStore, defineRegistry } from "@json-render/react";
+import { findFormValue } from "@json-render/core";
+import { useBoundProp, defineRegistry } from "@json-render/react";
 import {
   Bar,
   BarChart as RechartsBarChart,
@@ -136,22 +136,25 @@ export const { registry, handlers, executeAction } = defineRegistry(
         <Button
           variant={props.variant ?? "default"}
           disabled={loading || (props.disabled ?? false)}
-          onClick={() => emit?.("press")}
+          onClick={() => emit("press")}
         >
           {loading ? "..." : props.label}
         </Button>
       ),
 
-      Input: ({ props }) => {
-        const { state, set } = useStateStore();
+      Input: ({ props, bindings }) => {
+        const [value, setValue] = useBoundProp<string>(
+          props.value as string | undefined,
+          bindings?.value,
+        );
         return (
           <div className="flex flex-col gap-2">
             {props.label ? <Label>{props.label}</Label> : null}
             <Input
               type={props.type ?? "text"}
-              value={(getByPath(state, props.valuePath) as string) ?? ""}
+              value={value ?? ""}
               placeholder={props.placeholder ?? ""}
-              onChange={(e) => set(props.valuePath, e.target.value)}
+              onChange={(e) => setValue(e.target.value)}
             />
           </div>
         );
@@ -161,7 +164,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
         <form
           onSubmit={(e) => {
             e.preventDefault();
-            emit?.("submit");
+            emit("submit");
           }}
           className="flex flex-col gap-4"
         >
@@ -193,21 +196,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
         </Avatar>
       ),
 
-      Checkbox: ({ props }) => {
-        const { state, set } = useStateStore();
-        const checked =
-          (getByPath(state, props.valuePath) as boolean) ??
-          props.defaultChecked ??
-          false;
+      Checkbox: ({ props, bindings }) => {
+        const [checked, setChecked] = useBoundProp<boolean>(
+          props.checked as boolean | undefined,
+          bindings?.checked,
+        );
+        const isChecked = checked ?? props.defaultChecked ?? false;
         return (
           <div className="flex items-center gap-2">
             <Checkbox
-              id={props.valuePath}
-              checked={checked}
-              onCheckedChange={(value) => set(props.valuePath, value)}
+              id={bindings?.checked ?? "checkbox"}
+              checked={isChecked}
+              onCheckedChange={(value) => setChecked(value === true)}
             />
             {props.label ? (
-              <Label htmlFor={props.valuePath}>{props.label}</Label>
+              <Label htmlFor={bindings?.checked ?? "checkbox"}>
+                {props.label}
+              </Label>
             ) : null}
           </div>
         );
@@ -303,17 +308,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
         <Progress value={props.value} max={props.max ?? 100} />
       ),
 
-      RadioGroup: ({ props }) => {
-        const { state, set } = useStateStore();
-        const value =
-          (getByPath(state, props.valuePath) as string) ??
-          props.defaultValue ??
-          "";
+      RadioGroup: ({ props, bindings }) => {
+        const [value, setValue] = useBoundProp<string>(
+          props.value as string | undefined,
+          bindings?.value,
+        );
+        const current = value ?? props.defaultValue ?? "";
         return (
-          <RadioGroup
-            value={value}
-            onValueChange={(v) => set(props.valuePath, v)}
-          >
+          <RadioGroup value={current} onValueChange={(v) => setValue(v)}>
             {props.options.map((option) => (
               <div key={option.value} className="flex items-center gap-2">
                 <RadioGroupItem value={option.value} id={option.value} />
@@ -324,11 +326,13 @@ export const { registry, handlers, executeAction } = defineRegistry(
         );
       },
 
-      Select: ({ props }) => {
-        const { state, set } = useStateStore();
-        const value = (getByPath(state, props.valuePath) as string) ?? "";
+      Select: ({ props, bindings }) => {
+        const [value, setValue] = useBoundProp<string>(
+          props.value as string | undefined,
+          bindings?.value,
+        );
         return (
-          <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
+          <Select value={value ?? ""} onValueChange={(v) => setValue(v)}>
             <SelectTrigger>
               <SelectValue placeholder={props.placeholder ?? "Select..."} />
             </SelectTrigger>
@@ -359,21 +363,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
         );
       },
 
-      Switch: ({ props }) => {
-        const { state, set } = useStateStore();
-        const checked =
-          (getByPath(state, props.valuePath) as boolean) ??
-          props.defaultChecked ??
-          false;
+      Switch: ({ props, bindings }) => {
+        const [checked, setChecked] = useBoundProp<boolean>(
+          props.checked as boolean | undefined,
+          bindings?.checked,
+        );
+        const isChecked = checked ?? props.defaultChecked ?? false;
         return (
           <div className="flex items-center gap-2">
             <Switch
-              id={props.valuePath}
-              checked={checked}
-              onCheckedChange={(value) => set(props.valuePath, value)}
+              id={bindings?.checked ?? "switch"}
+              checked={isChecked}
+              onCheckedChange={(value) => setChecked(value)}
             />
             {props.label ? (
-              <Label htmlFor={props.valuePath}>{props.label}</Label>
+              <Label htmlFor={bindings?.checked ?? "switch"}>
+                {props.label}
+              </Label>
             ) : null}
           </div>
         );
@@ -396,16 +402,19 @@ export const { registry, handlers, executeAction } = defineRegistry(
         <TabsContent value={props.value}>{children}</TabsContent>
       ),
 
-      Textarea: ({ props }) => {
-        const { state, set } = useStateStore();
+      Textarea: ({ props, bindings }) => {
+        const [value, setValue] = useBoundProp<string>(
+          props.value as string | undefined,
+          bindings?.value,
+        );
         return (
           <div className="flex flex-col gap-2">
             {props.label ? <Label>{props.label}</Label> : null}
             <Textarea
-              value={(getByPath(state, props.valuePath) as string) ?? ""}
+              value={value ?? ""}
               placeholder={props.placeholder ?? ""}
               rows={props.rows ?? 3}
-              onChange={(e) => set(props.valuePath, e.target.value)}
+              onChange={(e) => setValue(e.target.value)}
             />
           </div>
         );
@@ -430,9 +439,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
       ),
 
       Table: ({ props }) => {
-        const { state } = useStateStore();
-        const path = props.statePath.replace(/\./g, "/");
-        const rawData = getByPath(state, path);
+        const rawData = props.data;
 
         const items: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData
@@ -497,9 +504,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       BarChart: ({ props }) => {
-        const { state } = useStateStore();
-        const path = props.statePath.replace(/\./g, "/");
-        const rawData = getByPath(state, path);
+        const rawData = props.data;
 
         const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData
@@ -564,9 +569,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       LineChart: ({ props }) => {
-        const { state } = useStateStore();
-        const path = props.statePath.replace(/\./g, "/");
-        const rawData = getByPath(state, path);
+        const rawData = props.data;
 
         const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData

+ 1 - 1
examples/react-native/lib/render/catalog.ts

@@ -11,7 +11,7 @@ import {
  *
  * Generic React Native rules (layout, tabs, navigation, element integrity)
  * are baked into the @json-render/react-native schema as defaultRules.
- * Core concepts (initial state, $path, Repeat, pushState/removeState)
+ * Core concepts (initial state, $state, Repeat, pushState/removeState)
  * are covered by the core system prompt.
  *
  * Only app-specific preferences belong here.

+ 35 - 25
examples/stripe-app/src/lib/render/catalog.ts

@@ -286,7 +286,7 @@ export const stripeCatalog = defineCatalog(schema, {
         placeholder: z.string().nullable(),
         description: z.string().nullable(),
         error: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         type: z
           .enum(["text", "email", "password", "number", "tel", "url"])
           .default("text"),
@@ -294,11 +294,12 @@ export const stripeCatalog = defineCatalog(schema, {
         disabled: z.boolean().nullable(),
         required: z.boolean().nullable(),
       }),
-      description: "Text input field with label, validation, and data binding",
+      description:
+        "Text input field with label, validation. Use $bindState on value for two-way binding.",
       example: {
         label: "Email",
         placeholder: "customer@example.com",
-        valuePath: "/form/email",
+        value: { $bindState: "/form/email" },
         type: "email",
       },
     },
@@ -309,12 +310,13 @@ export const stripeCatalog = defineCatalog(schema, {
         placeholder: z.string().nullable(),
         description: z.string().nullable(),
         error: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         rows: z.number().default(3),
         disabled: z.boolean().nullable(),
         required: z.boolean().nullable(),
       }),
-      description: "Multi-line text input with configurable rows",
+      description:
+        "Multi-line text input with configurable rows. Use $bindState on value for two-way binding.",
     },
 
     Select: {
@@ -322,13 +324,14 @@ export const stripeCatalog = defineCatalog(schema, {
         label: z.string(),
         description: z.string().nullable(),
         error: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         options: z.array(z.object({ value: z.string(), label: z.string() })),
         size: z.enum(["small", "medium", "large"]).default("medium"),
         disabled: z.boolean().nullable(),
         required: z.boolean().nullable(),
       }),
-      description: "Dropdown select input with configurable options",
+      description:
+        "Dropdown select input with configurable options. Use $bindState on value for two-way binding.",
     },
 
     Checkbox: {
@@ -336,34 +339,37 @@ export const stripeCatalog = defineCatalog(schema, {
         label: z.string(),
         description: z.string().nullable(),
         error: z.string().nullable(),
-        valuePath: z.string(),
+        checked: z.boolean().nullable(),
         defaultChecked: z.boolean().nullable(),
         disabled: z.boolean().nullable(),
       }),
-      description: "Checkbox input with label and description",
+      description:
+        "Checkbox input with label and description. Use $bindState on checked for two-way binding.",
     },
 
     Radio: {
       props: z.object({
         label: z.string(),
         description: z.string().nullable(),
-        valuePath: z.string(),
-        value: z.string(),
+        value: z.string().nullable(),
+        optionValue: z.string(),
         name: z.string(),
         disabled: z.boolean().nullable(),
       }),
-      description: "Radio button input for selecting one option from a group",
+      description:
+        "Radio button input for selecting one option from a group. Use $bindState on value for two-way binding.",
     },
 
     Switch: {
       props: z.object({
         label: z.string(),
         description: z.string().nullable(),
-        valuePath: z.string(),
+        checked: z.boolean().nullable(),
         defaultChecked: z.boolean().nullable(),
         disabled: z.boolean().nullable(),
       }),
-      description: "Toggle switch for boolean values",
+      description:
+        "Toggle switch for boolean values. Use $bindState on checked for two-way binding.",
     },
 
     DateField: {
@@ -371,11 +377,12 @@ export const stripeCatalog = defineCatalog(schema, {
         label: z.string(),
         description: z.string().nullable(),
         error: z.string().nullable(),
-        valuePath: z.string(),
+        value: z.string().nullable(),
         size: z.enum(["small", "medium", "large"]).default("medium"),
         disabled: z.boolean().nullable(),
       }),
-      description: "Date input field with date picker",
+      description:
+        "Date input field with date picker. Use $bindState on value for two-way binding.",
     },
 
     // =========================================================================
@@ -424,7 +431,7 @@ export const stripeCatalog = defineCatalog(schema, {
     // =========================================================================
     BarChart: {
       props: z.object({
-        statePath: z.string(),
+        data: z.array(z.record(z.unknown())).nullable(),
         xKey: z.string(),
         yKey: z.string(),
         colorKey: z.string().nullable(),
@@ -433,12 +440,13 @@ export const stripeCatalog = defineCatalog(schema, {
         showLegend: z.boolean().nullable(),
         showTooltip: z.boolean().default(true),
       }),
-      description: "Bar chart visualization from data path",
+      description:
+        "Bar chart visualization. Use $state on data to bind to state array.",
     },
 
     LineChart: {
       props: z.object({
-        statePath: z.string(),
+        data: z.array(z.record(z.unknown())).nullable(),
         xKey: z.string(),
         yKey: z.string(),
         colorKey: z.string().nullable(),
@@ -447,17 +455,19 @@ export const stripeCatalog = defineCatalog(schema, {
         showLegend: z.boolean().nullable(),
         showTooltip: z.boolean().default(true),
       }),
-      description: "Line chart visualization from data path",
+      description:
+        "Line chart visualization. Use $state on data to bind to state array.",
     },
 
     Sparkline: {
       props: z.object({
-        statePath: z.string(),
+        data: z.array(z.record(z.unknown())).nullable(),
         xKey: z.string(),
         yKey: z.string(),
         showTooltip: z.boolean().nullable(),
       }),
-      description: "Compact sparkline chart for inline data visualization",
+      description:
+        "Compact sparkline chart for inline data visualization. Use $state on data to bind to state array.",
     },
 
     // =========================================================================
@@ -466,16 +476,16 @@ export const stripeCatalog = defineCatalog(schema, {
     DataTable: {
       props: z.object({
         title: z.string().nullable(),
-        statePath: z.string(),
+        data: z.array(z.record(z.unknown())).nullable(),
         columns: z.array(z.object({ key: z.string(), label: z.string() })),
         emptyMessage: z.string().nullable(),
         rowAction: z.string().nullable(),
       }),
       description:
-        "Data table with configurable columns and optional row actions",
+        "Data table with configurable columns and optional row actions. Use $state on data to bind to state array.",
       example: {
         title: "Recent Payments",
-        statePath: "/payments/data",
+        data: { $state: "/payments/data" },
         columns: [
           { key: "amount", label: "Amount" },
           { key: "status", label: "Status" },

+ 7 - 4
examples/stripe-app/src/lib/render/catalog/actions.ts

@@ -1432,8 +1432,8 @@ export const actionHandlers: Record<
   // Navigation Actions
   // ===========================================================================
   navigate: async (params) => {
-    if (params?.path) {
-      console.log("Navigate to:", params.path);
+    if (params?.statePath) {
+      console.log("Navigate to:", params.statePath);
       // In a real app, this would use the router
     }
   },
@@ -1481,8 +1481,11 @@ export const actionHandlers: Record<
   },
 
   setFormValue: async (params, setState) => {
-    if (params?.path) {
-      setState((prev) => ({ ...prev, [params.path as string]: params?.value }));
+    if (params?.statePath) {
+      setState((prev) => ({
+        ...prev,
+        [params.statePath as string]: params?.value,
+      }));
     }
   },
 

+ 49 - 107
examples/stripe-app/src/lib/render/catalog/components.tsx

@@ -331,11 +331,7 @@ export const List: FunctionComponent<ExtendedRenderProps> = ({
   children,
   emit,
 }) => {
-  return (
-    <UIList onAction={emit ? (_id) => emit("select") : undefined}>
-      {children}
-    </UIList>
-  );
+  return <UIList onAction={(_id) => emit("select")}>{children}</UIList>;
 };
 
 export const ListItem: FunctionComponent<ExtendedRenderProps> = ({
@@ -404,7 +400,7 @@ export const TaskListItem: FunctionComponent<ExtendedRenderProps> = ({
     <UITaskListItem
       title={String(title || "")}
       status={status as "not-started" | "in-progress" | "blocked" | "complete"}
-      onPress={action && emit ? () => emit("press") : undefined}
+      onPress={action ? () => emit("press") : undefined}
     />
   );
 };
@@ -423,7 +419,7 @@ export const Menu: FunctionComponent<ExtendedRenderProps> = ({
       trigger={
         <UIButton type="secondary">{String(triggerLabel || "Menu")}</UIButton>
       }
-      onAction={emit ? (_id) => emit("select") : undefined}
+      onAction={(_id) => emit("select")}
     >
       {children}
     </UIMenu>
@@ -442,7 +438,7 @@ export const MenuItem: FunctionComponent<ExtendedRenderProps> = ({
     <UIMenuItem
       id={String(id || "")}
       disabled={Boolean(disabled) || undefined}
-      onAction={action && emit ? () => emit("select") : undefined}
+      onAction={action ? () => emit("select") : undefined}
     >
       {String(label || "")}
     </UIMenuItem>
@@ -460,20 +456,17 @@ export const MenuGroup: FunctionComponent<ExtendedRenderProps> = ({
 // =========================================================================
 export const TextField: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  getValue,
 }) => {
   const {
     label,
     placeholder,
     description,
     error,
-    valuePath,
+    value = "",
     size = "medium",
     disabled,
     required,
   } = element.props as Record<string, unknown>;
-  const value =
-    getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
 
   return (
     <UITextField
@@ -481,7 +474,7 @@ export const TextField: FunctionComponent<ExtendedRenderProps> = ({
       placeholder={placeholder ? String(placeholder) : undefined}
       description={description ? String(description) : undefined}
       error={error ? String(error) : undefined}
-      value={value || ""}
+      value={String(value ?? "")}
       size={size as "small" | "medium" | "large"}
       disabled={Boolean(disabled) || undefined}
       required={Boolean(required) || undefined}
@@ -492,20 +485,17 @@ export const TextField: FunctionComponent<ExtendedRenderProps> = ({
 
 export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  getValue,
 }) => {
   const {
     label,
     placeholder,
     description,
     error,
-    valuePath,
+    value = "",
     rows = 3,
     disabled,
     required,
   } = element.props as Record<string, unknown>;
-  const value =
-    getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
 
   return (
     <UITextArea
@@ -513,7 +503,7 @@ export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
       placeholder={placeholder ? String(placeholder) : undefined}
       description={description ? String(description) : undefined}
       error={error ? String(error) : undefined}
-      value={value || ""}
+      value={String(value ?? "")}
       rows={Number(rows)}
       disabled={Boolean(disabled) || undefined}
       required={Boolean(required) || undefined}
@@ -522,22 +512,17 @@ export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
   );
 };
 
-export const Select: FunctionComponent<ExtendedRenderProps> = ({
-  element,
-  getValue,
-}) => {
+export const Select: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
   const {
     label,
     description,
     error,
-    valuePath,
+    value = "",
     options = [],
     size = "medium",
     disabled,
     required,
   } = element.props as Record<string, unknown>;
-  const value =
-    getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
   const opts = options as Array<{ value: string; label: string }>;
 
   return (
@@ -545,7 +530,7 @@ export const Select: FunctionComponent<ExtendedRenderProps> = ({
       label={String(label || "")}
       description={description ? String(description) : undefined}
       error={error ? String(error) : undefined}
-      value={value || ""}
+      value={String(value ?? "")}
       size={size as "small" | "medium" | "large"}
       disabled={Boolean(disabled) || undefined}
       required={Boolean(required) || undefined}
@@ -562,65 +547,56 @@ export const Select: FunctionComponent<ExtendedRenderProps> = ({
 
 export const Checkbox: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  getValue,
 }) => {
-  const { label, description, error, valuePath, defaultChecked, disabled } =
+  const { label, description, error, checked, defaultChecked, disabled } =
     element.props as Record<string, unknown>;
-  const checked =
-    getValue && valuePath
-      ? (getValue(String(valuePath)) as boolean)
-      : Boolean(defaultChecked);
+  const isChecked = checked ?? defaultChecked;
 
   return (
     <UICheckbox
       label={String(label || "")}
       description={description ? String(description) : undefined}
       error={error ? String(error) : undefined}
-      checked={checked || undefined}
+      checked={Boolean(isChecked) || undefined}
       disabled={Boolean(disabled) || undefined}
       onChange={() => undefined}
     />
   );
 };
 
-export const Radio: FunctionComponent<ExtendedRenderProps> = ({
-  element,
-  getValue,
-}) => {
-  const { label, description, valuePath, value, name, disabled } =
-    element.props as Record<string, unknown>;
-  const currentValue =
-    getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
+export const Radio: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
+  const {
+    label,
+    description,
+    value: selectedValue,
+    optionValue,
+    name,
+    disabled,
+  } = element.props as Record<string, unknown>;
 
   return (
     <UIRadio
       label={String(label || "")}
       description={description ? String(description) : undefined}
-      value={String(value || "")}
+      value={String(optionValue ?? "")}
       name={String(name || "")}
-      checked={currentValue === value}
+      checked={String(selectedValue ?? "") === String(optionValue ?? "")}
       disabled={Boolean(disabled) || undefined}
       onChange={() => undefined}
     />
   );
 };
 
-export const Switch: FunctionComponent<ExtendedRenderProps> = ({
-  element,
-  getValue,
-}) => {
-  const { label, description, valuePath, defaultChecked, disabled } =
+export const Switch: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
+  const { label, description, checked, defaultChecked, disabled } =
     element.props as Record<string, unknown>;
-  const checked =
-    getValue && valuePath
-      ? (getValue(String(valuePath)) as boolean)
-      : Boolean(defaultChecked);
+  const isChecked = checked ?? defaultChecked;
 
   return (
     <UISwitch
       label={String(label || "")}
       description={description ? String(description) : undefined}
-      checked={checked || undefined}
+      checked={Boolean(isChecked) || undefined}
       disabled={Boolean(disabled) || undefined}
       onChange={() => undefined}
     />
@@ -629,25 +605,22 @@ export const Switch: FunctionComponent<ExtendedRenderProps> = ({
 
 export const DateField: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  getValue,
 }) => {
   const {
     label,
     description,
     error,
-    valuePath,
+    value = "",
     size = "medium",
     disabled,
   } = element.props as Record<string, unknown>;
-  const value =
-    getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
 
   return (
     <UIDateField
       label={String(label || "")}
       description={description ? String(description) : undefined}
       error={error ? String(error) : undefined}
-      value={value || ""}
+      value={String(value ?? "")}
       size={size as "small" | "medium" | "large"}
       disabled={Boolean(disabled) || undefined}
       onChange={() => undefined}
@@ -680,7 +653,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
       disabled={Boolean(disabled) || undefined}
       pending={Boolean(pending) || undefined}
       href={href ? String(href) : undefined}
-      onPress={action && emit ? () => emit("press") : undefined}
+      onPress={action ? () => emit("press") : undefined}
     >
       {String(label || "")}
     </UIButton>
@@ -716,10 +689,9 @@ export const Link: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
 // =========================================================================
 export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  state,
 }) => {
   const {
-    statePath,
+    data = [],
     xKey,
     yKey,
     colorKey,
@@ -729,11 +701,7 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
     showTooltip = true,
   } = element.props as Record<string, unknown>;
 
-  // Get data from path
-  const chartData =
-    statePath && state
-      ? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
-      : [];
+  const chartData = Array.isArray(data) ? data : [];
 
   if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
     return (
@@ -757,10 +725,9 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
 
 export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  state,
 }) => {
   const {
-    statePath,
+    data = [],
     xKey,
     yKey,
     colorKey,
@@ -770,10 +737,7 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
     showTooltip = true,
   } = element.props as Record<string, unknown>;
 
-  const chartData =
-    statePath && state
-      ? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
-      : [];
+  const chartData = Array.isArray(data) ? data : [];
 
   if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
     return (
@@ -797,17 +761,15 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
 
 export const Sparkline: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  state,
 }) => {
-  const { statePath, xKey, yKey, showTooltip } = element.props as Record<
-    string,
-    unknown
-  >;
+  const {
+    data = [],
+    xKey,
+    yKey,
+    showTooltip,
+  } = element.props as Record<string, unknown>;
 
-  const chartData =
-    statePath && state
-      ? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
-      : [];
+  const chartData = Array.isArray(data) ? data : [];
 
   if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
     return <Box css={{ color: "secondary", font: "caption" }}>—</Box>;
@@ -828,19 +790,15 @@ export const Sparkline: FunctionComponent<ExtendedRenderProps> = ({
 // =========================================================================
 export const DataTable: FunctionComponent<ExtendedRenderProps> = ({
   element,
-  state,
 }) => {
   const {
     title,
-    statePath,
+    data = [],
     columns = [],
     emptyMessage,
   } = element.props as Record<string, unknown>;
 
-  const tableData =
-    statePath && state
-      ? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
-      : [];
+  const tableData = Array.isArray(data) ? data : [];
   const cols = columns as Array<{ key: string; label: string }>;
 
   if (!tableData || tableData.length === 0) {
@@ -922,7 +880,7 @@ export const CustomerCard: FunctionComponent<ExtendedRenderProps> = ({
         </UIBadge>
       </Box>
       <Box css={{ color: "secondary" }}>{String(email || "")}</Box>
-      {customerId && emit && (
+      {customerId && (
         <UIButton type="secondary" size="small" onPress={() => emit("press")}>
           View Details
         </UIButton>
@@ -975,7 +933,7 @@ export const PaymentCard: FunctionComponent<ExtendedRenderProps> = ({
       {description && (
         <Box css={{ color: "secondary" }}>{String(description)}</Box>
       )}
-      {paymentId && emit && (
+      {paymentId && (
         <Box css={{ stack: "x", gap: "small" }}>
           <UIButton type="secondary" size="small" onPress={() => emit("press")}>
             View
@@ -1101,7 +1059,7 @@ export const InvoiceCard: FunctionComponent<ExtendedRenderProps> = ({
           Due: {String(dueDate)}
         </Box>
       )}
-      {status === "open" && emit && (
+      {status === "open" && (
         <UIButton type="primary" size="small" onPress={() => emit("press")}>
           Send Invoice
         </UIButton>
@@ -1258,7 +1216,7 @@ export const Chip: FunctionComponent<ExtendedRenderProps> = ({
     <UIChip
       label={String(label || "")}
       value={value ? String(value) : undefined}
-      onClose={removable && action && emit ? () => emit("remove") : undefined}
+      onClose={removable && action ? () => emit("remove") : undefined}
     />
   );
 };
@@ -1311,22 +1269,6 @@ export const Fallback: FunctionComponent<ExtendedRenderProps> = ({
   );
 };
 
-// =========================================================================
-// Utility Functions
-// =========================================================================
-function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
-  const parts = path.replace(/^\//, "").split("/");
-  let current: unknown = obj;
-  for (const part of parts) {
-    if (current && typeof current === "object" && part in current) {
-      current = (current as Record<string, unknown>)[part];
-    } else {
-      return undefined;
-    }
-  }
-  return current;
-}
-
 // =========================================================================
 // Component Map Export
 // =========================================================================

+ 2 - 1
examples/stripe-app/src/lib/render/renderer.tsx

@@ -47,6 +47,7 @@ function buildRegistry(
   const registry: ComponentRegistry = {};
 
   for (const [name, Component] of Object.entries(components)) {
+    const noop = () => {};
     registry[name] = (renderProps: {
       element: { type: string; props: Record<string, unknown> };
       children?: ReactNode;
@@ -55,7 +56,7 @@ function buildRegistry(
       Component({
         element: renderProps.element,
         children: renderProps.children,
-        emit: renderProps.emit,
+        emit: renderProps.emit ?? noop,
         loading,
         state: dataRef.current,
         getValue: (path: string) => {

+ 3 - 0
package.json

@@ -29,10 +29,13 @@
     "@changesets/cli": "2.29.8",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.1",
+    "@types/react": "^19.2.3",
     "husky": "^9.1.7",
     "jsdom": "^27.4.0",
     "lint-staged": "^16.2.7",
     "prettier": "^3.7.4",
+    "react": "^19.2.4",
+    "react-dom": "^19.2.4",
     "turbo": "^2.7.4",
     "typescript": "5.9.2",
     "vitest": "^4.0.17"

+ 12 - 12
packages/codegen/README.md

@@ -17,23 +17,23 @@ pnpm add @json-render/codegen
 ### Tree Traversal
 
 ```typescript
-import { traverseTree, collectUsedComponents, collectStatePaths, collectActions } from '@json-render/codegen';
+import { traverseSpec, collectUsedComponents, collectStatePaths, collectActions } from '@json-render/codegen';
 
-// Walk the tree depth-first
-traverseTree(tree, (element, depth, parent) => {
-  console.log(`${' '.repeat(depth * 2)}${element.type}`);
+// Walk the spec depth-first
+traverseSpec(spec, (element, key, depth, parent) => {
+  console.log(`${' '.repeat(depth * 2)}${key}: ${element.type}`);
 });
 
 // Get all component types used
-const components = collectUsedComponents(tree);
+const components = collectUsedComponents(spec);
 // Set { 'Card', 'Metric', 'Button' }
 
 // Get all state paths referenced
-const statePaths = collectStatePaths(tree);
+const statePaths = collectStatePaths(spec);
 // Set { 'analytics/revenue', 'user/name' }
 
 // Get all action names
-const actions = collectActions(tree);
+const actions = collectActions(spec);
 // Set { 'submit_form', 'refresh_data' }
 ```
 
@@ -49,8 +49,8 @@ serializePropValue("hello");
 serializePropValue(42);
 // { value: '42', needsBraces: true }
 
-serializePropValue({ path: 'user/name' });
-// { value: '{ path: "user/name" }', needsBraces: true }
+serializePropValue({ $state: '/user/name' });
+// { value: '{ $state: "/user/name" }', needsBraces: true }
 
 // Serialize props for JSX
 serializeProps({ title: "Dashboard", columns: 3, disabled: true });
@@ -64,7 +64,7 @@ import type { GeneratedFile, CodeGenerator } from '@json-render/codegen';
 
 // Implement your own code generator
 const myGenerator: CodeGenerator = {
-  generate(tree) {
+  generate(spec) {
     return [
       { path: 'package.json', content: '...' },
       { path: 'app/page.tsx', content: '...' },
@@ -81,7 +81,7 @@ See the `examples/dashboard` for a complete example of building a Next.js code g
 import { 
   collectUsedComponents, 
   collectStatePaths,
-  traverseTree,
+  traverseSpec,
   serializeProps,
   type GeneratedFile 
 } from '@json-render/codegen';
@@ -89,7 +89,7 @@ import type { Spec } from '@json-render/core';
 
 export function generateNextJSProject(spec: Spec): GeneratedFile[] {
   const files: GeneratedFile[] = [];
-  const components = collectUsedComponents(tree);
+  const components = collectUsedComponents(spec);
   
   // Generate package.json
   files.push({

+ 4 - 4
packages/codegen/src/serialize.ts

@@ -77,13 +77,13 @@ export function serializePropValue(
   }
 
   if (typeof value === "object") {
-    // Check for path reference
+    // Check for $state reference
     if (
-      "path" in value &&
-      typeof (value as { path: unknown }).path === "string"
+      "$state" in value &&
+      typeof (value as { $state: unknown }).$state === "string"
     ) {
       return {
-        value: `{ path: ${q}${escapeString((value as { path: string }).path, opts.quotes)}${q} }`,
+        value: `{ $state: ${q}${escapeString((value as { $state: string }).$state, opts.quotes)}${q} }`,
         needsBraces: true,
       };
     }

+ 54 - 4
packages/codegen/src/traverse.test.ts

@@ -72,13 +72,13 @@ describe("collectUsedComponents", () => {
 });
 
 describe("collectStatePaths", () => {
-  it("collects paths from valuePath props", () => {
+  it("collects paths from statePath props", () => {
     const spec: Spec = {
       root: "root",
       elements: {
         root: {
           type: "Metric",
-          props: { valuePath: "analytics/revenue" },
+          props: { statePath: "analytics/revenue" },
         },
       },
     };
@@ -93,13 +93,13 @@ describe("collectStatePaths", () => {
       elements: {
         root: {
           type: "Text",
-          props: { content: { path: "user/name" } },
+          props: { content: { $state: "/user/name" } },
         },
       },
     };
 
     const paths = collectStatePaths(spec);
-    expect(paths).toEqual(new Set(["user/name"]));
+    expect(paths).toEqual(new Set(["/user/name"]));
   });
 });
 
@@ -118,4 +118,54 @@ describe("collectActions", () => {
     const actions = collectActions(spec);
     expect(actions).toEqual(new Set(["submit_form"]));
   });
+
+  it("collects actions from on event bindings", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Button",
+          props: {},
+          on: { press: { action: "submitForm" } },
+        },
+      },
+    };
+
+    const actions = collectActions(spec);
+    expect(actions).toEqual(new Set(["submitForm"]));
+  });
+
+  it("collects actions from on array bindings", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Button",
+          props: {},
+          on: {
+            press: [{ action: "save" }, { action: "navigate" }],
+          },
+        },
+      },
+    };
+
+    const actions = collectActions(spec);
+    expect(actions).toEqual(new Set(["save", "navigate"]));
+  });
+
+  it("collects actions from both props and on", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Button",
+          props: { action: "submit_form" },
+          on: { press: { action: "setState", params: { statePath: "/x" } } },
+        },
+      },
+    };
+
+    const actions = collectActions(spec);
+    expect(actions).toEqual(new Set(["submit_form", "setState"]));
+  });
 });

+ 62 - 38
packages/codegen/src/traverse.ts

@@ -64,7 +64,7 @@ export function collectStatePaths(spec: Spec): Set<string> {
   traverseSpec(spec, (element, _key) => {
     // Check props for data paths
     for (const [propName, propValue] of Object.entries(element.props)) {
-      // Check for path props (e.g., valuePath, dataPath, bindPath)
+      // Check for path props (e.g., statePath, dataPath, bindPath)
       if (typeof propValue === "string") {
         if (
           propName.endsWith("Path") ||
@@ -75,19 +75,19 @@ export function collectStatePaths(spec: Spec): Set<string> {
         }
       }
 
-      // Check for dynamic value objects with path
+      // Check for dynamic value objects with $state
       if (
         propValue &&
         typeof propValue === "object" &&
-        "path" in propValue &&
-        typeof (propValue as { path: unknown }).path === "string"
+        "$state" in propValue &&
+        typeof (propValue as { $state: unknown }).$state === "string"
       ) {
-        paths.add((propValue as { path: string }).path);
+        paths.add((propValue as { $state: string }).$state);
       }
     }
 
-    // Check visibility conditions for paths
-    if (element.visible && typeof element.visible === "object") {
+    // Check visibility conditions for $state paths
+    if (element.visible != null && typeof element.visible !== "boolean") {
       collectPathsFromCondition(element.visible, paths);
     }
   });
@@ -95,49 +95,55 @@ export function collectStatePaths(spec: Spec): Set<string> {
   return paths;
 }
 
-function collectPathsFromCondition(
-  condition: unknown,
+function collectPathFromItem(
+  item: Record<string, unknown>,
   paths: Set<string>,
 ): void {
-  if (!condition || typeof condition !== "object") return;
-
-  const cond = condition as Record<string, unknown>;
-
-  if ("path" in cond && typeof cond.path === "string") {
-    paths.add(cond.path);
+  if (typeof item.$state === "string") {
+    paths.add(item.$state);
   }
-
-  if ("and" in cond && Array.isArray(cond.and)) {
-    for (const sub of cond.and) {
-      collectPathsFromCondition(sub, paths);
+  // Also collect $state references in comparison values (eq, neq, etc.)
+  for (const op of ["eq", "neq", "gt", "gte", "lt", "lte"]) {
+    const val = item[op];
+    if (
+      val &&
+      typeof val === "object" &&
+      "$state" in (val as Record<string, unknown>) &&
+      typeof (val as Record<string, unknown>).$state === "string"
+    ) {
+      paths.add((val as { $state: string }).$state);
     }
   }
+}
+
+function collectPathsFromCondition(
+  condition: unknown,
+  paths: Set<string>,
+): void {
+  if (!condition || typeof condition !== "object") return;
 
-  if ("or" in cond && Array.isArray(cond.or)) {
-    for (const sub of cond.or) {
-      collectPathsFromCondition(sub, paths);
+  // Array = implicit AND
+  if (Array.isArray(condition)) {
+    for (const item of condition) {
+      if (item && typeof item === "object") {
+        collectPathFromItem(item as Record<string, unknown>, paths);
+      }
     }
+    return;
   }
 
-  if ("not" in cond) {
-    collectPathsFromCondition(cond.not, paths);
-  }
+  const cond = condition as Record<string, unknown>;
 
-  // Check comparison operators
-  for (const op of ["eq", "neq", "gt", "gte", "lt", "lte"]) {
-    if (op in cond && Array.isArray(cond[op])) {
-      for (const operand of cond[op] as unknown[]) {
-        if (
-          operand &&
-          typeof operand === "object" &&
-          "path" in operand &&
-          typeof (operand as { path: unknown }).path === "string"
-        ) {
-          paths.add((operand as { path: string }).path);
-        }
-      }
+  // $or: recurse into each child condition
+  if ("$or" in cond && Array.isArray(cond.$or)) {
+    for (const child of cond.$or) {
+      collectPathsFromCondition(child, paths);
     }
+    return;
   }
+
+  // Single StateCondition
+  collectPathFromItem(cond, paths);
 }
 
 /**
@@ -169,6 +175,24 @@ export function collectActions(spec: Spec): Set<string> {
     if (typeof actionProp === "string") {
       actions.add(actionProp);
     }
+
+    // Collect actions from on event bindings
+    const onBindings = element.on;
+    if (onBindings) {
+      for (const binding of Object.values(onBindings)) {
+        const bindings = Array.isArray(binding) ? binding : [binding];
+        for (const b of bindings) {
+          if (
+            b &&
+            typeof b === "object" &&
+            "action" in b &&
+            typeof (b as { action: unknown }).action === "string"
+          ) {
+            actions.add((b as { action: string }).action);
+          }
+        }
+      }
+    }
   });
 
   return actions;

+ 91 - 16
packages/core/README.md

@@ -27,7 +27,7 @@ export const schema = defineSchema((s) => ({
     root: s.object({
       type: s.ref("catalog.components"),
       props: s.propsOf("catalog.components"),
-      children: s.array(s.self()),
+      children: s.array(s.string()), // Element keys (flat spec format)
     }),
   }),
   catalog: s.object({
@@ -119,9 +119,9 @@ const finalSpec = compiler.getResult();
 SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
 
 ```jsonl
-{"op":"add","path":"/root/type","value":"Card"}
-{"op":"add","path":"/root/props","value":{"title":"Hello"}}
-{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
+{"op":"add","path":"/root","value":"card-1"}
+{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":["btn-1"]}}
+{"op":"add","path":"/elements/btn-1","value":{"type":"Button","props":{"label":"Click"},"children":[]}}
 ```
 
 All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
@@ -192,7 +192,7 @@ const spec = compileSpecStream<MySpec>(jsonlString);
 
 | Export | Purpose |
 |--------|---------|
-| `validateSpec(spec, catalog?)` | Validate spec structure and return issues |
+| `validateSpec(spec, options?)` | Validate spec structure and return issues |
 | `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) |
 | `formatSpecIssues(issues)` | Format validation issues as readable strings |
 
@@ -211,17 +211,33 @@ const spec = compileSpecStream<MySpec>(jsonlString);
 
 Any prop value can be a dynamic expression that resolves based on data state at render time. Expressions are resolved by the renderer before props reach components.
 
-### Data Binding (`$path`)
+### Data Binding (`$state`)
 
 Read a value directly from the state model:
 
 ```json
 {
-  "color": { "$path": "/theme/primary" },
-  "label": { "$path": "/user/name" }
+  "color": { "$state": "/theme/primary" },
+  "label": { "$state": "/user/name" }
 }
 ```
 
+### Two-Way Binding (`$bindState` / `$bindItem`)
+
+Use `{ "$bindState": "/path" }` on the natural value prop for form components that need read/write access. The component reads from and writes to the state path:
+
+```json
+{
+  "type": "Input",
+  "props": {
+    "value": { "$bindState": "/form/email" },
+    "placeholder": "Email"
+  }
+}
+```
+
+Inside a repeat scope, use `{ "$bindItem": "completed" }` to bind to a field on the current item:
+
 ### Conditional (`$cond` / `$then` / `$else`)
 
 Evaluate a condition (same syntax as visibility conditions) and pick a value:
@@ -229,12 +245,12 @@ Evaluate a condition (same syntax as visibility conditions) and pick a value:
 ```json
 {
   "color": {
-    "$cond": { "eq": [{ "path": "/activeTab" }, "home"] },
+    "$cond": { "$state": "/activeTab", "eq": "home" },
     "$then": "#007AFF",
     "$else": "#8E8E93"
   },
   "name": {
-    "$cond": { "eq": [{ "path": "/activeTab" }, "home"] },
+    "$cond": { "$state": "/activeTab", "eq": "home" },
     "$then": "home",
     "$else": "home-outline"
   }
@@ -246,13 +262,33 @@ Evaluate a condition (same syntax as visibility conditions) and pick a value:
 ```json
 {
   "label": {
-    "$cond": { "path": "/user/isAdmin" },
-    "$then": { "$path": "/admin/greeting" },
+    "$cond": { "$state": "/user/isAdmin" },
+    "$then": { "$state": "/admin/greeting" },
     "$else": "Welcome"
   }
 }
 ```
 
+### Repeat Item (`$item`)
+
+Inside children of a repeated element, read a field from the current array item:
+
+```json
+{ "$item": "title" }
+```
+
+Use `""` to get the entire item object. `$item` takes a path string because items are typically objects with nested fields to navigate.
+
+### Repeat Index (`$index`)
+
+Get the current array index inside a repeat:
+
+```json
+{ "$index": true }
+```
+
+`$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path).
+
 ### API
 
 ```typescript
@@ -260,7 +296,7 @@ import { resolvePropValue, resolveElementProps } from "@json-render/core";
 
 // Resolve a single value
 const color = resolvePropValue(
-  { $cond: { eq: [{ path: "/active" }, "yes"] }, $then: "blue", $else: "gray" },
+  { $cond: { $state: "/active", eq: "yes" }, $then: "blue", $else: "gray" },
   { stateModel: myState }
 );
 
@@ -268,6 +304,45 @@ const color = resolvePropValue(
 const resolved = resolveElementProps(element.props, { stateModel: myState });
 ```
 
+## Visibility Conditions
+
+Visibility conditions control when elements are shown. `VisibilityContext` is `{ stateModel: StateModel, repeatItem?: unknown, repeatIndex?: number }`.
+
+### Syntax
+
+```typescript
+{ "$state": "/path" }                          // truthiness
+{ "$state": "/path", "not": true }             // falsy
+{ "$state": "/path", "eq": value }             // equality
+{ "$state": "/path", "neq": value }            // inequality
+{ "$state": "/path", "gt": number }            // greater than
+{ "$item": "field" }                          // repeat item field
+{ "$index": true, "gt": 0 }                   // repeat index
+[ condition, condition ]                       // implicit AND
+{ "$and": [ condition, condition ] }           // explicit AND
+{ "$or": [ condition, condition ] }            // OR
+true / false                                   // always / never
+```
+
+### TypeScript Helpers
+
+```typescript
+import { visibility } from "@json-render/core";
+
+visibility.always              // true
+visibility.never               // false
+visibility.when("/path")       // { $state: "/path" }
+visibility.unless("/path")     // { $state: "/path", not: true }
+visibility.eq("/path", val)    // { $state: "/path", eq: val }
+visibility.neq("/path", val)   // { $state: "/path", neq: val }
+visibility.gt("/path", n)      // { $state: "/path", gt: n }
+visibility.gte("/path", n)     // { $state: "/path", gte: n }
+visibility.lt("/path", n)      // { $state: "/path", lt: n }
+visibility.lte("/path", n)     // { $state: "/path", lte: n }
+visibility.and(cond1, cond2)   // { $and: [cond1, cond2] }
+visibility.or(cond1, cond2)    // { $or: [cond1, cond2] }
+```
+
 ## User Prompt Builder
 
 Build structured user prompts for AI generation, with support for refinement and state context:
@@ -299,7 +374,7 @@ Validate spec structure and auto-fix common issues:
 import { validateSpec, autoFixSpec, formatSpecIssues } from "@json-render/core";
 
 // Validate a spec
-const { valid, issues } = validateSpec(spec, catalog);
+const { valid, issues } = validateSpec(spec);
 
 // Format issues for display
 console.log(formatSpecIssues(issues));
@@ -313,8 +388,8 @@ const fixed = autoFixSpec(spec);
 json-render supports completely different spec formats for different renderers:
 
 ```typescript
-// React: Element tree
-{ root: { type: "Card", props: {...}, children: [...] } }
+// React: Flat element map
+{ root: "card-1", elements: { "card-1": { type: "Card", props: {...}, children: [...] } } }
 
 // Remotion: Timeline
 { composition: {...}, tracks: [...], clips: [...] }

+ 3 - 3
packages/core/src/actions.test.ts

@@ -49,14 +49,14 @@ describe("resolveAction", () => {
     expect(resolved.params.count).toBe(5);
   });
 
-  it("resolves dynamic path params", () => {
+  it("resolves dynamic $state params", () => {
     const data = { userId: 123, settings: { theme: "dark" } };
     const resolved = resolveAction(
       {
         action: "updateUser",
         params: {
-          id: { path: "/userId" },
-          theme: { path: "/settings/theme" },
+          id: { $state: "/userId" },
+          theme: { $state: "/settings/theme" },
         },
       },
       data,

+ 2 - 2
packages/core/src/actions.ts

@@ -33,7 +33,7 @@ export type ActionOnError =
  *
  * Used inside the `on` field of a UIElement:
  * ```json
- * { "on": { "press": { "action": "setState", "params": { "path": "/x", "value": 1 } } } }
+ * { "on": { "press": { "action": "setState", "params": { "statePath": "/x", "value": 1 } } } }
  * ```
  */
 export interface ActionBinding {
@@ -169,7 +169,7 @@ export function interpolateString(
   stateModel: StateModel,
 ): string {
   return template.replace(/\$\{([^}]+)\}/g, (_, path) => {
-    const value = resolveDynamicValue({ path }, stateModel);
+    const value = resolveDynamicValue({ $state: path }, stateModel);
     return String(value ?? "");
   });
 }

+ 0 - 717
packages/core/src/catalog.test.ts

@@ -1,717 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { z } from "zod";
-import {
-  createCatalog,
-  generateCatalogPrompt,
-  generateSystemPrompt,
-} from "./catalog";
-import { defineSchema, defineCatalog } from "./schema";
-
-describe("createCatalog", () => {
-  it("creates catalog with components", () => {
-    const catalog = createCatalog({
-      components: {
-        text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text",
-        },
-        button: {
-          props: z.object({ label: z.string() }),
-          description: "A clickable button",
-        },
-      },
-    });
-
-    expect(catalog.componentNames).toHaveLength(2);
-    expect(catalog.hasComponent("text")).toBe(true);
-    expect(catalog.hasComponent("button")).toBe(true);
-    expect(catalog.hasComponent("unknown")).toBe(false);
-  });
-
-  it("creates catalog with actions", () => {
-    const catalog = createCatalog({
-      components: {
-        button: { props: z.object({ label: z.string() }) },
-      },
-      actions: {
-        navigate: { description: "Navigate to URL" },
-        submit: { description: "Submit form" },
-      },
-    });
-
-    expect(catalog.actionNames).toHaveLength(2);
-    expect(catalog.hasAction("navigate")).toBe(true);
-    expect(catalog.hasAction("submit")).toBe(true);
-    expect(catalog.hasAction("unknown")).toBe(false);
-  });
-
-  it("creates catalog with custom validation functions", () => {
-    const catalog = createCatalog({
-      components: {
-        input: { props: z.object({ value: z.string() }) },
-      },
-      functions: {
-        customValidator: {
-          validate: (value) => typeof value === "string" && value.length > 0,
-          description: "Custom validation",
-        },
-      },
-    });
-
-    expect(catalog.functionNames).toHaveLength(1);
-    expect(catalog.hasFunction("customValidator")).toBe(true);
-  });
-
-  it("validates elements correctly", () => {
-    const catalog = createCatalog({
-      components: {
-        text: {
-          props: z.object({ content: z.string() }),
-        },
-      },
-    });
-
-    const validElement = {
-      type: "text",
-      props: { content: "Hello" },
-    };
-    const invalidElement = {
-      type: "text",
-      props: { content: 123 },
-    };
-
-    expect(catalog.validateElement(validElement).success).toBe(true);
-    expect(catalog.validateElement(invalidElement).success).toBe(false);
-  });
-
-  it("validates UI specs", () => {
-    const catalog = createCatalog({
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const validSpec = {
-      root: "1",
-      elements: {
-        "1": { type: "text", props: { content: "Hello" } },
-      },
-    };
-
-    expect(catalog.validateSpec(validSpec).success).toBe(true);
-  });
-
-  it("validates nested specs with children", () => {
-    const catalog = createCatalog({
-      components: {
-        card: {
-          props: z.object({ title: z.string() }),
-          hasChildren: true,
-        },
-        text: {
-          props: z.object({ content: z.string() }),
-        },
-      },
-    });
-
-    const validSpec = {
-      root: "card-1",
-      elements: {
-        "card-1": {
-          type: "card",
-          props: { title: "Hello" },
-          children: ["text-1"],
-        },
-        "text-1": {
-          type: "text",
-          props: { content: "World" },
-        },
-      },
-    };
-
-    expect(catalog.validateSpec(validSpec).success).toBe(true);
-  });
-
-  it("rejects specs with invalid component types", () => {
-    const catalog = createCatalog({
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const invalidSpec = {
-      root: "1",
-      elements: {
-        "1": { type: "nonexistent", props: { content: "Hello" } },
-      },
-    };
-
-    expect(catalog.validateSpec(invalidSpec).success).toBe(false);
-  });
-
-  it("uses default name when not provided", () => {
-    const catalog = createCatalog({
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    expect(catalog.name).toBe("unnamed");
-  });
-
-  it("uses provided name", () => {
-    const catalog = createCatalog({
-      name: "MyCatalog",
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    expect(catalog.name).toBe("MyCatalog");
-  });
-});
-
-describe("generateCatalogPrompt", () => {
-  it("generates prompt containing catalog name", () => {
-    const catalog = createCatalog({
-      name: "TestCatalog",
-      components: {
-        text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text content",
-        },
-      },
-    });
-
-    const prompt = generateCatalogPrompt(catalog);
-
-    expect(prompt).toContain("TestCatalog");
-  });
-
-  it("includes component descriptions", () => {
-    const catalog = createCatalog({
-      components: {
-        text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text content",
-        },
-      },
-    });
-
-    const prompt = generateCatalogPrompt(catalog);
-
-    expect(prompt).toContain("text");
-    expect(prompt).toContain("Display text content");
-  });
-
-  it("includes action descriptions", () => {
-    const catalog = createCatalog({
-      components: {
-        button: { props: z.object({ label: z.string() }) },
-      },
-      actions: {
-        alert: { description: "Show alert message" },
-      },
-    });
-
-    const prompt = generateCatalogPrompt(catalog);
-
-    expect(prompt).toContain("alert");
-    expect(prompt).toContain("Show alert message");
-  });
-
-  it("includes visibility documentation", () => {
-    const catalog = createCatalog({
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const prompt = generateCatalogPrompt(catalog);
-
-    expect(prompt).toContain("Visibility");
-    expect(prompt).toContain("visible");
-  });
-
-  it("includes validation documentation", () => {
-    const catalog = createCatalog({
-      components: {
-        text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const prompt = generateCatalogPrompt(catalog);
-
-    expect(prompt).toContain("Validation");
-    expect(prompt).toContain("required");
-    expect(prompt).toContain("email");
-  });
-});
-
-describe("generateSystemPrompt", () => {
-  it("generates a complete system prompt", () => {
-    const catalog = createCatalog({
-      components: {
-        Card: {
-          props: z.object({ title: z.string() }),
-          description: "A card container",
-          hasChildren: true,
-        },
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text",
-        },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog);
-
-    expect(prompt).toContain("You are a UI generator");
-    expect(prompt).toContain("AVAILABLE COMPONENTS");
-    expect(prompt).toContain("Card");
-    expect(prompt).toContain("Text");
-  });
-
-  it("includes prop types in prompt", () => {
-    const catalog = createCatalog({
-      components: {
-        Button: {
-          props: z.object({
-            label: z.string(),
-            variant: z.enum(["primary", "secondary"]).optional(),
-            disabled: z.boolean().optional(),
-          }),
-          description: "Clickable button",
-        },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog);
-
-    // Should include component name and description
-    expect(prompt).toContain("Button");
-    expect(prompt).toContain("Clickable button");
-    // Should include AVAILABLE COMPONENTS section
-    expect(prompt).toContain("AVAILABLE COMPONENTS");
-  });
-
-  it("includes actions in prompt", () => {
-    const catalog = createCatalog({
-      components: {
-        Button: { props: z.object({ label: z.string() }) },
-      },
-      actions: {
-        navigate: { description: "Navigate to a URL" },
-        submit: { description: "Submit the form" },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog);
-
-    expect(prompt).toContain("AVAILABLE ACTIONS");
-    expect(prompt).toContain("navigate");
-    expect(prompt).toContain("Navigate to a URL");
-    expect(prompt).toContain("submit");
-  });
-
-  it("includes output format rules", () => {
-    const catalog = createCatalog({
-      components: {
-        Text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog);
-
-    expect(prompt).toContain("OUTPUT FORMAT");
-    expect(prompt).toContain("RULES");
-    expect(prompt).toContain("/root");
-    expect(prompt).toContain("/elements");
-  });
-
-  it("allows custom system message", () => {
-    const catalog = createCatalog({
-      components: {
-        Text: { props: z.object({ content: z.string() }) },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog, {
-      system: "You are a dashboard builder.",
-    });
-
-    expect(prompt).toContain("You are a dashboard builder.");
-    expect(prompt).not.toContain("You are a UI generator");
-  });
-
-  it("appends custom rules", () => {
-    const catalog = createCatalog({
-      components: {
-        Card: { props: z.object({ title: z.string() }) },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog, {
-      customRules: [
-        "Always use Card as the root element",
-        "Keep UIs simple and clean",
-      ],
-    });
-
-    expect(prompt).toContain("Always use Card as the root element");
-    expect(prompt).toContain("Keep UIs simple and clean");
-  });
-
-  it("includes custom validation functions when present", () => {
-    const catalog = createCatalog({
-      components: {
-        Input: { props: z.object({ value: z.string() }) },
-      },
-      functions: {
-        phoneNumber: {
-          validate: (v) => /^\d{10}$/.test(String(v)),
-          description: "Validates phone numbers",
-        },
-        zipCode: {
-          validate: (v) => /^\d{5}$/.test(String(v)),
-          description: "Validates zip codes",
-        },
-      },
-    });
-
-    const prompt = generateSystemPrompt(catalog);
-
-    expect(prompt).toContain("CUSTOM VALIDATION FUNCTIONS");
-    expect(prompt).toContain("phoneNumber");
-    expect(prompt).toContain("zipCode");
-  });
-});
-
-describe("defineCatalog (new schema API)", () => {
-  const testSchema = defineSchema((s) => ({
-    spec: s.object({
-      root: s.string(),
-      elements: s.record(
-        s.object({
-          type: s.ref("catalog.components"),
-          props: s.any(),
-          children: s.array(s.string()),
-        }),
-      ),
-    }),
-    catalog: s.object({
-      components: s.map({
-        props: s.zod(),
-        description: s.string(),
-        example: s.any(),
-      }),
-      actions: s.map({
-        description: s.string(),
-      }),
-    }),
-  }));
-
-  it("creates a catalog from a schema", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text",
-        },
-        Button: {
-          props: z.object({ label: z.string() }),
-          description: "Clickable button",
-        },
-      },
-      actions: {
-        click: { description: "Handle click event" },
-      },
-    });
-
-    expect(catalog.componentNames).toEqual(["Text", "Button"]);
-    expect(catalog.actionNames).toEqual(["click"]);
-  });
-
-  it("generates prompt from schema-based catalog", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Card: {
-          props: z.object({ title: z.string() }),
-          description: "A card container",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    expect(prompt).toContain("Card");
-    expect(prompt).toContain("A card container");
-    expect(prompt).toContain("AVAILABLE COMPONENTS");
-  });
-
-  it("supports custom rules in prompt", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Text component",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt({
-      customRules: ["Always use semantic HTML", "Keep layouts responsive"],
-    });
-
-    expect(prompt).toContain("Always use semantic HTML");
-    expect(prompt).toContain("Keep layouts responsive");
-  });
-
-  it("validates specs against catalog", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Text component",
-        },
-      },
-      actions: {},
-    });
-
-    const validSpec = {
-      root: "text-1",
-      elements: {
-        "text-1": {
-          type: "Text",
-          props: { content: "Hello" },
-          children: [],
-        },
-      },
-    };
-
-    const result = catalog.validate(validSpec);
-    expect(result.success).toBe(true);
-    expect(result.data).toEqual(validSpec);
-  });
-
-  it("returns errors for invalid specs", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Text component",
-        },
-      },
-      actions: {},
-    });
-
-    const invalidSpec = {
-      root: 123, // Should be string
-      elements: {},
-    };
-
-    const result = catalog.validate(invalidSpec);
-    expect(result.success).toBe(false);
-    expect(result.error).toBeDefined();
-  });
-
-  it("generates JSON Schema for structured outputs", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Text component",
-        },
-      },
-      actions: {},
-    });
-
-    const jsonSchema = catalog.jsonSchema();
-
-    // jsonSchema() returns a JSON Schema representation
-    expect(jsonSchema).toBeDefined();
-    expect(typeof jsonSchema).toBe("object");
-  });
-
-  it("provides Zod schema for custom validation", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Text component",
-        },
-      },
-      actions: {},
-    });
-
-    const zodSchema = catalog.zodSchema();
-
-    const result = zodSchema.safeParse({
-      root: "text-1",
-      elements: {
-        "text-1": {
-          type: "Text",
-          props: { content: "Hello" },
-          children: [],
-        },
-      },
-    });
-
-    expect(result.success).toBe(true);
-  });
-
-  it("does not include hardcoded component names in prompt (issue #88)", () => {
-    // When a catalog only has "Text", the generated prompt should NOT
-    // reference components like Stack, Grid, Heading, Card, Column,
-    // Button, or Pressable that are not in the catalog.
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({ content: z.string() }),
-          description: "Display text content",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    // The prompt should contain the actual catalog component
-    expect(prompt).toContain("Text");
-    expect(prompt).toContain("Display text content");
-
-    // The prompt should NOT contain hardcoded component names not in the catalog
-    // Check that these don't appear as component types in JSON examples
-    const hardcodedComponents = [
-      "Stack",
-      "Grid",
-      "Heading",
-      "Card",
-      "Column",
-      "Pressable",
-    ];
-
-    for (const comp of hardcodedComponents) {
-      // Check for "type":"<ComponentName>" patterns in JSON examples
-      expect(prompt).not.toContain(`"type":"${comp}"`);
-      // Also check for "type": "<ComponentName>" with space
-      expect(prompt).not.toContain(`"type": "${comp}"`);
-    }
-  });
-
-  it("uses actual catalog component names in prompt examples", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        MyBox: {
-          props: z.object({ padding: z.number() }),
-          description: "A box",
-        },
-        MyLabel: {
-          props: z.object({ text: z.string() }),
-          description: "A label",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    // The example output should use MyBox and MyLabel, not hardcoded names
-    expect(prompt).toContain('"type":"MyBox"');
-    expect(prompt).toContain('"type":"MyLabel"');
-
-    // Should not contain any hardcoded component names in type fields
-    expect(prompt).not.toContain('"type":"Stack"');
-    expect(prompt).not.toContain('"type":"Grid"');
-    expect(prompt).not.toContain('"type":"Heading"');
-    expect(prompt).not.toContain('"type":"Column"');
-    expect(prompt).not.toContain('"type":"Button"');
-    expect(prompt).not.toContain('"type":"Pressable"');
-  });
-
-  it("generates example props from Zod schema when no example provided", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Text: {
-          props: z.object({
-            content: z.string(),
-            size: z.number(),
-            bold: z.boolean(),
-            variant: z.enum(["body", "heading"]),
-            color: z.string().optional(),
-          }),
-          description: "Display text",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    // Required props should appear with generated values in examples
-    // (string -> "example", number -> 0, boolean -> true, enum -> first value)
-    expect(prompt).toContain('"content":"example"');
-    expect(prompt).toContain('"size":0');
-    expect(prompt).toContain('"bold":true');
-    expect(prompt).toContain('"variant":"body"');
-
-    // Optional props should NOT appear in examples (keeps them concise)
-    // The prop name "color" should still appear in the AVAILABLE COMPONENTS
-    // section but not in the JSON example objects
-    const exampleSection = prompt.split("AVAILABLE COMPONENTS")[0]!;
-    expect(exampleSection).not.toContain('"color"');
-
-    // Prompt examples should never have empty props:{}
-    expect(exampleSection).not.toContain('"props":{}');
-  });
-
-  it("uses explicit example field over Zod-generated values", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Heading: {
-          props: z.object({
-            text: z.string(),
-            level: z.enum(["h1", "h2", "h3"]),
-          }),
-          description: "A heading",
-          example: { text: "Welcome to My App", level: "h1" },
-        },
-        Paragraph: {
-          props: z.object({ content: z.string() }),
-          description: "A paragraph",
-          example: { content: "Lorem ipsum dolor sit amet" },
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    // Should use the explicit example values, not "example" or first enum value
-    expect(prompt).toContain('"text":"Welcome to My App"');
-    expect(prompt).toContain('"level":"h1"');
-    expect(prompt).toContain('"content":"Lorem ipsum dolor sit amet"');
-  });
-
-  it("uses $path binding on first string prop for repeat example", () => {
-    const catalog = defineCatalog(testSchema, {
-      components: {
-        Card: {
-          props: z.object({
-            title: z.string(),
-            subtitle: z.string(),
-          }),
-          description: "A card",
-        },
-      },
-      actions: {},
-    });
-
-    const prompt = catalog.prompt();
-
-    // In the repeat/item example, the first string prop should get a $path binding
-    expect(prompt).toContain('"title":{"$path":"$item/title"}');
-  });
-});

+ 0 - 534
packages/core/src/catalog.ts

@@ -1,534 +0,0 @@
-import { z } from "zod";
-import type {
-  ComponentSchema,
-  ValidationMode,
-  UIElement,
-  Spec,
-  VisibilityCondition,
-} from "./types";
-import { VisibilityConditionSchema } from "./visibility";
-import { ActionSchema, type ActionDefinition } from "./actions";
-import { ValidationConfigSchema, type ValidationFunction } from "./validation";
-
-/**
- * Component definition with visibility and validation support
- */
-export interface ComponentDefinition<
-  TProps extends ComponentSchema = ComponentSchema,
-> {
-  /** Zod schema for component props */
-  props: TProps;
-  /** Whether this component can have children */
-  hasChildren?: boolean;
-  /** Description for AI generation */
-  description?: string;
-  /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
-  example?: Record<string, unknown>;
-}
-
-/**
- * Catalog configuration
- */
-export interface CatalogConfig<
-  TComponents extends Record<string, ComponentDefinition> = Record<
-    string,
-    ComponentDefinition
-  >,
-  TActions extends Record<string, ActionDefinition> = Record<
-    string,
-    ActionDefinition
-  >,
-  TFunctions extends Record<string, ValidationFunction> = Record<
-    string,
-    ValidationFunction
-  >,
-> {
-  /** Catalog name */
-  name?: string;
-  /** Component definitions */
-  components: TComponents;
-  /** Action definitions with param schemas */
-  actions?: TActions;
-  /** Custom validation functions */
-  functions?: TFunctions;
-  /** Validation mode */
-  validation?: ValidationMode;
-}
-
-/**
- * Catalog instance
- */
-export interface Catalog<
-  TComponents extends Record<string, ComponentDefinition> = Record<
-    string,
-    ComponentDefinition
-  >,
-  TActions extends Record<string, ActionDefinition> = Record<
-    string,
-    ActionDefinition
-  >,
-  TFunctions extends Record<string, ValidationFunction> = Record<
-    string,
-    ValidationFunction
-  >,
-> {
-  /** Catalog name */
-  readonly name: string;
-  /** Component names */
-  readonly componentNames: (keyof TComponents)[];
-  /** Action names */
-  readonly actionNames: (keyof TActions)[];
-  /** Function names */
-  readonly functionNames: (keyof TFunctions)[];
-  /** Validation mode */
-  readonly validation: ValidationMode;
-  /** Component definitions */
-  readonly components: TComponents;
-  /** Action definitions */
-  readonly actions: TActions;
-  /** Custom validation functions */
-  readonly functions: TFunctions;
-  /** Full element schema for AI generation */
-  readonly elementSchema: z.ZodType<UIElement>;
-  /** Full UI spec schema */
-  readonly specSchema: z.ZodType<Spec>;
-  /** Check if component exists */
-  hasComponent(type: string): boolean;
-  /** Check if action exists */
-  hasAction(name: string): boolean;
-  /** Check if function exists */
-  hasFunction(name: string): boolean;
-  /** Validate an element */
-  validateElement(element: unknown): {
-    success: boolean;
-    data?: UIElement;
-    error?: z.ZodError;
-  };
-  /** Validate a UI spec */
-  validateSpec(spec: unknown): {
-    success: boolean;
-    data?: Spec;
-    error?: z.ZodError;
-  };
-}
-
-/**
- * Create a v2 catalog with visibility, actions, and validation support
- */
-export function createCatalog<
-  TComponents extends Record<string, ComponentDefinition>,
-  TActions extends Record<string, ActionDefinition> = Record<
-    string,
-    ActionDefinition
-  >,
-  TFunctions extends Record<string, ValidationFunction> = Record<
-    string,
-    ValidationFunction
-  >,
->(
-  config: CatalogConfig<TComponents, TActions, TFunctions>,
-): Catalog<TComponents, TActions, TFunctions> {
-  const {
-    name = "unnamed",
-    components,
-    actions = {} as TActions,
-    functions = {} as TFunctions,
-    validation = "strict",
-  } = config;
-
-  const componentNames = Object.keys(components) as (keyof TComponents)[];
-  const actionNames = Object.keys(actions) as (keyof TActions)[];
-  const functionNames = Object.keys(functions) as (keyof TFunctions)[];
-
-  // Create element schema for each component type
-  const componentSchemas = componentNames.map((componentName) => {
-    const def = components[componentName]!;
-
-    return z.object({
-      type: z.literal(componentName as string),
-      props: def.props,
-      children: z.array(z.string()).optional(),
-      visible: VisibilityConditionSchema.optional(),
-    });
-  });
-
-  // Create union schema for all components
-  let elementSchema: z.ZodType<UIElement>;
-
-  if (componentSchemas.length === 0) {
-    elementSchema = z.object({
-      type: z.string(),
-      props: z.record(z.string(), z.unknown()),
-      children: z.array(z.string()).optional(),
-      visible: VisibilityConditionSchema.optional(),
-    }) as unknown as z.ZodType<UIElement>;
-  } else if (componentSchemas.length === 1) {
-    elementSchema = componentSchemas[0] as unknown as z.ZodType<UIElement>;
-  } else {
-    elementSchema = z.discriminatedUnion("type", [
-      componentSchemas[0] as z.ZodObject<any>,
-      componentSchemas[1] as z.ZodObject<any>,
-      ...(componentSchemas.slice(2) as z.ZodObject<any>[]),
-    ]) as unknown as z.ZodType<UIElement>;
-  }
-
-  // Create spec schema
-  const specSchema = z.object({
-    root: z.string(),
-    elements: z.record(z.string(), elementSchema),
-  }) as unknown as z.ZodType<Spec>;
-
-  return {
-    name,
-    componentNames,
-    actionNames,
-    functionNames,
-    validation,
-    components,
-    actions,
-    functions,
-    elementSchema,
-    specSchema,
-
-    hasComponent(type: string) {
-      return type in components;
-    },
-
-    hasAction(name: string) {
-      return name in actions;
-    },
-
-    hasFunction(name: string) {
-      return name in functions;
-    },
-
-    validateElement(element: unknown) {
-      const result = elementSchema.safeParse(element);
-      if (result.success) {
-        return { success: true, data: result.data };
-      }
-      return { success: false, error: result.error };
-    },
-
-    validateSpec(spec: unknown) {
-      const result = specSchema.safeParse(spec);
-      if (result.success) {
-        return { success: true, data: result.data };
-      }
-      return { success: false, error: result.error };
-    },
-  };
-}
-
-/**
- * Generate a prompt for AI that describes the catalog
- */
-export function generateCatalogPrompt<
-  TComponents extends Record<string, ComponentDefinition>,
-  TActions extends Record<string, ActionDefinition>,
-  TFunctions extends Record<string, ValidationFunction>,
->(catalog: Catalog<TComponents, TActions, TFunctions>): string {
-  const lines: string[] = [
-    `# ${catalog.name} Component Catalog`,
-    "",
-    "## Available Components",
-    "",
-  ];
-
-  // Components
-  for (const name of catalog.componentNames) {
-    const def = catalog.components[name]!;
-    lines.push(`### ${String(name)}`);
-    if (def.description) {
-      lines.push(def.description);
-    }
-    lines.push("");
-  }
-
-  // Actions
-  if (catalog.actionNames.length > 0) {
-    lines.push("## Available Actions");
-    lines.push("");
-    for (const name of catalog.actionNames) {
-      const def = catalog.actions[name]!;
-      lines.push(
-        `- \`${String(name)}\`${def.description ? `: ${def.description}` : ""}`,
-      );
-    }
-    lines.push("");
-  }
-
-  // Visibility
-  lines.push("## Visibility Conditions");
-  lines.push("");
-  lines.push("Components can have a `visible` property:");
-  lines.push("- `true` / `false` - Always visible/hidden");
-  lines.push('- `{ "path": "/state/path" }` - Visible when path is truthy');
-  lines.push('- `{ "auth": "signedIn" }` - Visible when user is signed in');
-  lines.push('- `{ "and": [...] }` - All conditions must be true');
-  lines.push('- `{ "or": [...] }` - Any condition must be true');
-  lines.push('- `{ "not": {...} }` - Negates a condition');
-  lines.push('- `{ "eq": [a, b] }` - Equality check');
-  lines.push("");
-
-  // Validation
-  lines.push("## Validation Functions");
-  lines.push("");
-  lines.push(
-    "Built-in: `required`, `email`, `minLength`, `maxLength`, `pattern`, `min`, `max`, `url`",
-  );
-  if (catalog.functionNames.length > 0) {
-    lines.push(`Custom: ${catalog.functionNames.map(String).join(", ")}`);
-  }
-  lines.push("");
-
-  return lines.join("\n");
-}
-
-/**
- * Type helper to infer component props from catalog
- */
-export type InferCatalogComponentProps<
-  C extends Catalog<Record<string, ComponentDefinition>>,
-> = {
-  [K in keyof C["components"]]: z.infer<C["components"][K]["props"]>;
-};
-
-/**
- * Internal Zod definition type for introspection
- */
-interface ZodDefInternal {
-  typeName?: string;
-  value?: unknown;
-  values?: unknown;
-  type?: z.ZodTypeAny;
-  shape?: () => Record<string, z.ZodTypeAny>;
-  innerType?: z.ZodTypeAny;
-  options?: z.ZodTypeAny[];
-}
-
-/**
- * Format a Zod type into a human-readable string for prompts
- */
-function formatZodType(schema: z.ZodTypeAny, isOptional = false): string {
-  const def = schema._def as unknown as ZodDefInternal;
-  const typeName = def.typeName ?? "";
-
-  let result: string;
-
-  switch (typeName) {
-    case "ZodString":
-      result = "string";
-      break;
-    case "ZodNumber":
-      result = "number";
-      break;
-    case "ZodBoolean":
-      result = "boolean";
-      break;
-    case "ZodLiteral":
-      result = JSON.stringify(def.value);
-      break;
-    case "ZodEnum":
-      result = (def.values as string[]).map((v) => `"${v}"`).join("|");
-      break;
-    case "ZodNativeEnum":
-      result = Object.values(def.values as Record<string, string>)
-        .map((v) => `"${v}"`)
-        .join("|");
-      break;
-    case "ZodArray":
-      result = def.type
-        ? `Array<${formatZodType(def.type)}>`
-        : "Array<unknown>";
-      break;
-    case "ZodObject": {
-      if (!def.shape) {
-        result = "object";
-        break;
-      }
-      const shape = def.shape();
-      const props = Object.entries(shape)
-        .map(([key, value]) => {
-          const innerDef = value._def as unknown as ZodDefInternal;
-          const innerOptional =
-            innerDef.typeName === "ZodOptional" ||
-            innerDef.typeName === "ZodNullable";
-          return `${key}${innerOptional ? "?" : ""}: ${formatZodType(value)}`;
-        })
-        .join(", ");
-      result = `{ ${props} }`;
-      break;
-    }
-    case "ZodOptional":
-      return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
-    case "ZodNullable":
-      return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
-    case "ZodDefault":
-      return def.innerType
-        ? formatZodType(def.innerType, isOptional)
-        : "unknown";
-    case "ZodUnion":
-      result = def.options
-        ? def.options.map((opt) => formatZodType(opt)).join("|")
-        : "unknown";
-      break;
-    case "ZodNull":
-      result = "null";
-      break;
-    case "ZodUndefined":
-      result = "undefined";
-      break;
-    case "ZodAny":
-      result = "any";
-      break;
-    case "ZodUnknown":
-      result = "unknown";
-      break;
-    default:
-      result = "unknown";
-  }
-
-  return isOptional ? `${result}?` : result;
-}
-
-/**
- * Extract props from a Zod object schema as formatted entries
- */
-function extractPropsFromSchema(
-  schema: z.ZodTypeAny,
-): Array<{ name: string; type: string; optional: boolean }> {
-  const def = schema._def as unknown as ZodDefInternal;
-  const typeName = def.typeName ?? "";
-
-  if (typeName !== "ZodObject" || !def.shape) {
-    return [];
-  }
-
-  const shape = def.shape();
-  return Object.entries(shape).map(([name, value]) => {
-    const innerDef = value._def as unknown as ZodDefInternal;
-    const optional =
-      innerDef.typeName === "ZodOptional" ||
-      innerDef.typeName === "ZodNullable";
-    return {
-      name,
-      type: formatZodType(value),
-      optional,
-    };
-  });
-}
-
-/**
- * Format component props as a compact object notation
- */
-function formatPropsCompact(
-  props: Array<{ name: string; type: string; optional: boolean }>,
-): string {
-  if (props.length === 0) return "{}";
-  const entries = props.map(
-    (p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`,
-  );
-  return `{ ${entries.join(", ")} }`;
-}
-
-/**
- * Options for generating system prompts
- */
-export interface SystemPromptOptions {
-  /** System message intro (replaces default) */
-  system?: string;
-  /** Additional rules to append to the rules section */
-  customRules?: string[];
-}
-
-/**
- * Generate a complete system prompt for AI that can generate UI from a catalog.
- * This produces a ready-to-use prompt that stays in sync with the catalog definition.
- */
-export function generateSystemPrompt<
-  TComponents extends Record<string, ComponentDefinition>,
-  TActions extends Record<string, ActionDefinition>,
-  TFunctions extends Record<string, ValidationFunction>,
->(
-  catalog: Catalog<TComponents, TActions, TFunctions>,
-  options: SystemPromptOptions = {},
-): string {
-  const {
-    system = "You are a UI generator that outputs JSONL (JSON Lines) patches.",
-    customRules = [],
-  } = options;
-
-  const lines: string[] = [];
-
-  // System intro
-  lines.push(system);
-  lines.push("");
-
-  // Components section
-  const componentCount = catalog.componentNames.length;
-  lines.push(`AVAILABLE COMPONENTS (${componentCount}):`);
-  lines.push("");
-
-  for (const name of catalog.componentNames) {
-    const def = catalog.components[name]!;
-    const props = extractPropsFromSchema(def.props);
-    const propsStr = formatPropsCompact(props);
-    const hasChildrenStr = def.hasChildren ? " Has children." : "";
-    const descStr = def.description ? ` ${def.description}` : "";
-
-    lines.push(`- ${String(name)}: ${propsStr}${descStr}${hasChildrenStr}`);
-  }
-  lines.push("");
-
-  // Actions section
-  if (catalog.actionNames.length > 0) {
-    lines.push("AVAILABLE ACTIONS:");
-    lines.push("");
-    for (const name of catalog.actionNames) {
-      const def = catalog.actions[name]!;
-      lines.push(
-        `- ${String(name)}${def.description ? `: ${def.description}` : ""}`,
-      );
-    }
-    lines.push("");
-  }
-
-  // Output format
-  lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
-  lines.push('{"op":"add","path":"/root","value":"element-key"}');
-  lines.push(
-    '{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
-  );
-  lines.push('{"op":"remove","path":"/elements/key"}');
-  lines.push("");
-
-  // Rules
-  lines.push("RULES:");
-  const baseRules = [
-    'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
-    'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
-    "Remove elements with op:remove - also update the parent's children array to exclude the removed key",
-    "Children array contains string keys, not objects",
-    "Parent first, then children",
-    "Each element needs: type, props",
-    "ONLY use props listed above - never invent new props",
-  ];
-  const allRules = [...baseRules, ...customRules];
-  allRules.forEach((rule, i) => {
-    lines.push(`${i + 1}. ${rule}`);
-  });
-  lines.push("");
-
-  // Custom validation functions (only if catalog has them)
-  if (catalog.functionNames.length > 0) {
-    lines.push("CUSTOM VALIDATION FUNCTIONS:");
-    lines.push(catalog.functionNames.map(String).join(", "));
-    lines.push("");
-  }
-
-  // End with prompt
-  lines.push("Generate JSONL:");
-
-  return lines.join("\n");
-}

+ 38 - 26
packages/core/src/index.ts

@@ -8,8 +8,12 @@ export type {
   FlatElement,
   Spec,
   VisibilityCondition,
-  LogicExpression,
-  AuthState,
+  StateCondition,
+  ItemCondition,
+  IndexCondition,
+  SingleCondition,
+  AndCondition,
+  OrCondition,
   StateModel,
   ComponentSchema,
   ValidationMode,
@@ -18,6 +22,12 @@ export type {
   // SpecStream types
   SpecStreamLine,
   SpecStreamCompiler,
+  // Mixed stream types (chat + GenUI)
+  MixedStreamCallbacks,
+  MixedStreamParser,
+  // AI SDK stream transform
+  StreamChunk,
+  SpecDataPart,
 } from "./types";
 
 export {
@@ -34,8 +44,17 @@ export {
   // SpecStream - streaming format for building specs (RFC 6902)
   parseSpecStreamLine,
   applySpecStreamPatch,
+  applySpecPatch,
+  nestedToFlat,
   compileSpecStream,
   createSpecStreamCompiler,
+  // Mixed stream parser (chat + GenUI)
+  createMixedStreamParser,
+  // AI SDK stream transform
+  createJsonRenderTransform,
+  pipeJsonRender,
+  SPEC_DATA_PART,
+  SPEC_DATA_PART_TYPE,
 } from "./types";
 
 // Visibility
@@ -43,16 +62,19 @@ export type { VisibilityContext } from "./visibility";
 
 export {
   VisibilityConditionSchema,
-  LogicExpressionSchema,
   evaluateVisibility,
-  evaluateLogicExpression,
   visibility,
 } from "./visibility";
 
 // Prop Expressions
-export type { PropExpression } from "./props";
+export type { PropExpression, PropResolutionContext } from "./props";
 
-export { resolvePropValue, resolveElementProps } from "./props";
+export {
+  resolvePropValue,
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+} from "./props";
 
 // Actions
 export type {
@@ -113,45 +135,35 @@ export type {
 
 export { validateSpec, autoFixSpec, formatSpecIssues } from "./spec-validator";
 
-// Schema (new API)
+// Schema — defines the grammar (how specs and catalogs are structured)
 export type {
   SchemaBuilder,
   SchemaType,
   SchemaDefinition,
   Schema,
+  PromptTemplate,
+  SchemaOptions,
+} from "./schema";
+
+export { defineSchema } from "./schema";
+
+// Catalog — defines the vocabulary (what components and actions are available)
+export type {
   Catalog,
   PromptOptions,
   PromptContext,
-  PromptTemplate,
-  SchemaOptions,
   SpecValidationResult,
   InferCatalogInput,
   InferSpec,
-  // Catalog type inference
   InferCatalogComponents,
   InferCatalogActions,
   InferComponentProps,
   InferActionParams,
 } from "./schema";
 
-export { defineSchema, defineCatalog } from "./schema";
+export { defineCatalog } from "./schema";
 
 // User Prompt Builder
 export type { UserPromptOptions } from "./prompt";
 
 export { buildUserPrompt } from "./prompt";
-
-// Legacy Catalog (for backwards compatibility during migration)
-export type {
-  ComponentDefinition,
-  CatalogConfig,
-  Catalog as LegacyCatalog,
-  InferCatalogComponentProps,
-  SystemPromptOptions,
-} from "./catalog";
-
-export {
-  createCatalog,
-  generateCatalogPrompt,
-  generateSystemPrompt,
-} from "./catalog";

+ 500 - 0
packages/core/src/props.test.ts

@@ -0,0 +1,500 @@
+import { describe, it, expect } from "vitest";
+import {
+  resolvePropValue,
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+} from "./props";
+import type { PropResolutionContext } from "./props";
+
+// =============================================================================
+// resolvePropValue
+// =============================================================================
+
+describe("resolvePropValue", () => {
+  describe("literals", () => {
+    it("passes through strings", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue("hello", ctx)).toBe("hello");
+    });
+
+    it("passes through numbers", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue(42, ctx)).toBe(42);
+    });
+
+    it("passes through booleans", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue(true, ctx)).toBe(true);
+      expect(resolvePropValue(false, ctx)).toBe(false);
+    });
+
+    it("passes through null", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue(null, ctx)).toBeNull();
+    });
+
+    it("passes through undefined", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue(undefined, ctx)).toBeUndefined();
+    });
+  });
+
+  describe("$state expressions", () => {
+    it("resolves a state path", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { user: { name: "Alice" } },
+      };
+      expect(resolvePropValue({ $state: "/user/name" }, ctx)).toBe("Alice");
+    });
+
+    it("returns undefined for missing state path", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue({ $state: "/missing" }, ctx)).toBeUndefined();
+    });
+
+    it("resolves nested state path", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { a: { b: { c: 42 } } },
+      };
+      expect(resolvePropValue({ $state: "/a/b/c" }, ctx)).toBe(42);
+    });
+  });
+
+  describe("$item expressions", () => {
+    it("resolves a field from the repeat item", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { title: "Hello", id: "1" },
+        repeatIndex: 0,
+      };
+      expect(resolvePropValue({ $item: "title" }, ctx)).toBe("Hello");
+    });
+
+    it('resolves "/" to the whole item', () => {
+      const item = { title: "Hello", id: "1" };
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: item,
+        repeatIndex: 0,
+      };
+      expect(resolvePropValue({ $item: "" }, ctx)).toBe(item);
+    });
+
+    it("resolves nested field from item", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { user: { name: "Bob" } },
+        repeatIndex: 0,
+      };
+      expect(resolvePropValue({ $item: "user/name" }, ctx)).toBe("Bob");
+    });
+
+    it("returns undefined when no repeat item in context", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue({ $item: "title" }, ctx)).toBeUndefined();
+    });
+
+    it("returns undefined for missing field on item", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { title: "Hello" },
+        repeatIndex: 0,
+      };
+      expect(resolvePropValue({ $item: "missing" }, ctx)).toBeUndefined();
+    });
+  });
+
+  describe("$index expressions", () => {
+    it("returns the current repeat index", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { id: "1" },
+        repeatIndex: 3,
+      };
+      expect(resolvePropValue({ $index: true }, ctx)).toBe(3);
+    });
+
+    it("returns 0 for first item", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { id: "1" },
+        repeatIndex: 0,
+      };
+      expect(resolvePropValue({ $index: true }, ctx)).toBe(0);
+    });
+
+    it("returns undefined when no repeat index in context", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue({ $index: true }, ctx)).toBeUndefined();
+    });
+  });
+
+  describe("$cond/$then/$else expressions", () => {
+    it("returns $then when condition is true", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { active: true },
+      };
+      expect(
+        resolvePropValue(
+          { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
+          ctx,
+        ),
+      ).toBe("blue");
+    });
+
+    it("returns $else when condition is false", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { active: false },
+      };
+      expect(
+        resolvePropValue(
+          { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
+          ctx,
+        ),
+      ).toBe("gray");
+    });
+
+    it("handles eq condition", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { tab: "home" },
+      };
+      expect(
+        resolvePropValue(
+          {
+            $cond: { $state: "/tab", eq: "home" },
+            $then: "#007AFF",
+            $else: "#8E8E93",
+          },
+          ctx,
+        ),
+      ).toBe("#007AFF");
+    });
+
+    it("handles nested expression in $then/$else", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { isAdmin: true, admin: { greeting: "Hello Admin" } },
+      };
+      expect(
+        resolvePropValue(
+          {
+            $cond: { $state: "/isAdmin" },
+            $then: { $state: "/admin/greeting" },
+            $else: "Welcome",
+          },
+          ctx,
+        ),
+      ).toBe("Hello Admin");
+    });
+
+    it("handles array condition (implicit AND)", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { isAdmin: true, feature: true },
+      };
+      expect(
+        resolvePropValue(
+          {
+            $cond: [{ $state: "/isAdmin" }, { $state: "/feature" }],
+            $then: "yes",
+            $else: "no",
+          },
+          ctx,
+        ),
+      ).toBe("yes");
+    });
+  });
+
+  describe("nested objects and arrays", () => {
+    it("resolves expressions inside plain objects", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { color: "red", size: 12 },
+      };
+      const result = resolvePropValue(
+        { fill: { $state: "/color" }, fontSize: { $state: "/size" } },
+        ctx,
+      );
+      expect(result).toEqual({ fill: "red", fontSize: 12 });
+    });
+
+    it("resolves expressions inside arrays", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { a: 1, b: 2 },
+      };
+      const result = resolvePropValue(
+        [{ $state: "/a" }, { $state: "/b" }, 3],
+        ctx,
+      );
+      expect(result).toEqual([1, 2, 3]);
+    });
+
+    it("resolves deeply nested expressions", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { theme: { primary: "#007AFF" } },
+      };
+      const result = resolvePropValue(
+        { style: { color: { $state: "/theme/primary" }, margin: 10 } },
+        ctx,
+      );
+      expect(result).toEqual({ style: { color: "#007AFF", margin: 10 } });
+    });
+  });
+});
+
+// =============================================================================
+// resolveElementProps
+// =============================================================================
+
+describe("resolveElementProps", () => {
+  it("resolves all props in an element", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { user: { name: "Alice", role: "admin" } },
+    };
+    const props = {
+      label: { $state: "/user/name" },
+      badge: { $state: "/user/role" },
+      static: "always",
+    };
+    expect(resolveElementProps(props, ctx)).toEqual({
+      label: "Alice",
+      badge: "admin",
+      static: "always",
+    });
+  });
+
+  it("resolves mixed expressions and literals", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { active: true },
+      repeatItem: { title: "Item 1" },
+      repeatIndex: 2,
+    };
+    const props = {
+      title: { $item: "title" },
+      index: { $index: true },
+      color: {
+        $cond: { $state: "/active" },
+        $then: "green",
+        $else: "gray",
+      },
+      width: 100,
+    };
+    expect(resolveElementProps(props, ctx)).toEqual({
+      title: "Item 1",
+      index: 2,
+      color: "green",
+      width: 100,
+    });
+  });
+
+  it("returns empty object for empty props", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolveElementProps({}, ctx)).toEqual({});
+  });
+});
+
+// =============================================================================
+// $bindState / $bindItem expressions
+// =============================================================================
+
+describe("$bindState expressions", () => {
+  describe("resolvePropValue with $bindState", () => {
+    it("resolves to the state value at the path", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { form: { email: "alice@example.com" } },
+      };
+      expect(resolvePropValue({ $bindState: "/form/email" }, ctx)).toBe(
+        "alice@example.com",
+      );
+    });
+
+    it("returns undefined for missing path", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      expect(resolvePropValue({ $bindState: "/missing" }, ctx)).toBeUndefined();
+    });
+  });
+
+  describe("resolvePropValue with $bindItem", () => {
+    it("resolves item field using repeatBasePath", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { todos: [{ completed: true }, { completed: false }] },
+        repeatItem: { completed: true },
+        repeatIndex: 0,
+        repeatBasePath: "/todos/0",
+      };
+      expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBe(true);
+    });
+
+    it('handles "/" as the full item path', () => {
+      const ctx: PropResolutionContext = {
+        stateModel: { items: ["hello", "world"] },
+        repeatItem: "hello",
+        repeatIndex: 0,
+        repeatBasePath: "/items/0",
+      };
+      expect(resolvePropValue({ $bindItem: "" }, ctx)).toBe("hello");
+    });
+
+    it("returns undefined when no repeatBasePath", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { completed: true },
+        repeatIndex: 0,
+      };
+      // Without repeatBasePath, the raw item path won't resolve in stateModel
+      expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBeUndefined();
+    });
+  });
+
+  describe("resolveBindings", () => {
+    it("extracts $bindState paths from props", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      const props = {
+        value: { $bindState: "/form/email" },
+        label: "Email",
+        placeholder: "Enter email",
+      };
+      expect(resolveBindings(props, ctx)).toEqual({
+        value: "/form/email",
+      });
+    });
+
+    it("returns undefined when no bind expressions", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      const props = {
+        label: "Hello",
+        count: 42,
+      };
+      expect(resolveBindings(props, ctx)).toBeUndefined();
+    });
+
+    it("handles multiple $bindState props", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      const props = {
+        value: { $bindState: "/form/name" },
+        checked: { $bindState: "/form/agree" },
+        label: "Name",
+      };
+      expect(resolveBindings(props, ctx)).toEqual({
+        value: "/form/name",
+        checked: "/form/agree",
+      });
+    });
+
+    it("resolves $bindItem paths using repeatBasePath", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { completed: false },
+        repeatIndex: 1,
+        repeatBasePath: "/todos/1",
+      };
+      const props = {
+        checked: { $bindItem: "completed" },
+        label: { $item: "title" },
+      };
+      expect(resolveBindings(props, ctx)).toEqual({
+        checked: "/todos/1/completed",
+      });
+    });
+
+    it("ignores non-bind dynamic expressions", () => {
+      const ctx: PropResolutionContext = { stateModel: {} };
+      const props = {
+        title: { $state: "/title" },
+        index: { $index: true },
+        name: { $item: "name" },
+        value: { $bindState: "/path" },
+      };
+      expect(resolveBindings(props, ctx)).toEqual({
+        value: "/path",
+      });
+    });
+
+    it("handles mixed $bindState and $bindItem props", () => {
+      const ctx: PropResolutionContext = {
+        stateModel: {},
+        repeatItem: { done: false },
+        repeatIndex: 0,
+        repeatBasePath: "/todos/0",
+      };
+      const props = {
+        value: { $bindState: "/form/search" },
+        checked: { $bindItem: "done" },
+        label: "Task",
+      };
+      expect(resolveBindings(props, ctx)).toEqual({
+        value: "/form/search",
+        checked: "/todos/0/done",
+      });
+    });
+  });
+});
+
+// =============================================================================
+// resolveActionParam
+// =============================================================================
+
+describe("resolveActionParam", () => {
+  it("resolves $item to an absolute state path via repeatBasePath", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { todos: [{ title: "Buy milk" }] },
+      repeatItem: { title: "Buy milk" },
+      repeatIndex: 0,
+      repeatBasePath: "/todos/0",
+    };
+    expect(resolveActionParam({ $item: "title" }, ctx)).toBe("/todos/0/title");
+  });
+
+  it("resolves $item with empty string to the repeatBasePath itself", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { items: ["a", "b"] },
+      repeatItem: "a",
+      repeatIndex: 0,
+      repeatBasePath: "/items/0",
+    };
+    expect(resolveActionParam({ $item: "" }, ctx)).toBe("/items/0");
+  });
+
+  it("returns undefined for $item when no repeatBasePath", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: {},
+      repeatItem: { title: "Hello" },
+      repeatIndex: 0,
+    };
+    expect(resolveActionParam({ $item: "title" }, ctx)).toBeUndefined();
+  });
+
+  it("resolves $index to the current repeat index", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: {},
+      repeatItem: { id: "1" },
+      repeatIndex: 5,
+    };
+    expect(resolveActionParam({ $index: true }, ctx)).toBe(5);
+  });
+
+  it("returns undefined for $index when no repeat context", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolveActionParam({ $index: true }, ctx)).toBeUndefined();
+  });
+
+  it("delegates $state expressions to resolvePropValue", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { form: { id: "abc-123" } },
+    };
+    expect(resolveActionParam({ $state: "/form/id" }, ctx)).toBe("abc-123");
+  });
+
+  it("passes through literal strings", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolveActionParam("submit", ctx)).toBe("submit");
+  });
+
+  it("passes through literal numbers", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolveActionParam(42, ctx)).toBe(42);
+  });
+
+  it("passes through null", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolveActionParam(null, ctx)).toBeNull();
+  });
+});

+ 193 - 16
packages/core/src/props.ts

@@ -1,4 +1,4 @@
-import type { VisibilityCondition, StateModel, AuthState } from "./types";
+import type { VisibilityCondition, StateModel } from "./types";
 import { getByPath } from "./types";
 import { evaluateVisibility, type VisibilityContext } from "./visibility";
 
@@ -9,13 +9,28 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
 /**
  * A prop expression that resolves to a value based on state.
  *
- * - `{ $path: string }` reads a value from the state model
+ * - `{ $state: string }` reads a value from the global state model
+ * - `{ $item: string }` reads a field from the current repeat item
+ *    (relative path into the item object; use `""` for the whole item)
+ * - `{ $index: true }` returns the current repeat array index. Uses `true`
+ *    as a sentinel flag because the index is a scalar with no sub-path to
+ *    navigate — unlike `$item` which needs a path into the item object.
+ * - `{ $bindState: string }` two-way binding to a global state path —
+ *    resolves to the value at the path (like `$state`) AND exposes the
+ *    resolved path so the component can write back.
+ * - `{ $bindItem: string }` two-way binding to a field on the current
+ *    repeat item — resolves via `repeatBasePath + path` and exposes the
+ *    absolute state path for write-back.
  * - `{ $cond, $then, $else }` conditionally picks a value
  * - Any other value is a literal (passthrough)
  */
 export type PropExpression<T = unknown> =
   | T
-  | { $path: string }
+  | { $state: string }
+  | { $item: string }
+  | { $index: true }
+  | { $bindState: string }
+  | { $bindItem: string }
   | {
       $cond: VisibilityCondition;
       $then: PropExpression<T>;
@@ -23,20 +38,66 @@ export type PropExpression<T = unknown> =
     };
 
 /**
- * Check if a value is a $path expression
+ * Context for resolving prop expressions.
+ * Extends {@link VisibilityContext} with an optional `repeatBasePath` used
+ * to resolve `$bindItem` paths to absolute state paths.
  */
-function isPathExpression(value: unknown): value is { $path: string } {
+export interface PropResolutionContext extends VisibilityContext {
+  /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
+  repeatBasePath?: string;
+}
+
+// =============================================================================
+// Type Guards
+// =============================================================================
+
+function isStateExpression(value: unknown): value is { $state: string } {
   return (
     typeof value === "object" &&
     value !== null &&
-    "$path" in value &&
-    typeof (value as Record<string, unknown>).$path === "string"
+    "$state" in value &&
+    typeof (value as Record<string, unknown>).$state === "string"
+  );
+}
+
+function isItemExpression(value: unknown): value is { $item: string } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$item" in value &&
+    typeof (value as Record<string, unknown>).$item === "string"
+  );
+}
+
+function isIndexExpression(value: unknown): value is { $index: true } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$index" in value &&
+    (value as Record<string, unknown>).$index === true
+  );
+}
+
+function isBindStateExpression(
+  value: unknown,
+): value is { $bindState: string } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$bindState" in value &&
+    typeof (value as Record<string, unknown>).$bindState === "string"
+  );
+}
+
+function isBindItemExpression(value: unknown): value is { $bindItem: string } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$bindItem" in value &&
+    typeof (value as Record<string, unknown>).$bindItem === "string"
   );
 }
 
-/**
- * Check if a value is a $cond expression
- */
 function isCondExpression(
   value: unknown,
 ): value is { $cond: VisibilityCondition; $then: unknown; $else: unknown } {
@@ -53,21 +114,77 @@ function isCondExpression(
 // Prop Expression Resolution
 // =============================================================================
 
+// =============================================================================
+// $bindItem path resolution helper
+// =============================================================================
+
+/**
+ * Resolve a `$bindItem` path into an absolute state path using the repeat
+ * scope's base path.
+ *
+ * `""` resolves to `repeatBasePath` (the whole item).
+ * `"field"` resolves to `repeatBasePath + "/field"`.
+ *
+ * Returns `undefined` when no `repeatBasePath` is available (i.e. `$bindItem`
+ * is used outside a repeat scope).
+ */
+function resolveBindItemPath(
+  itemPath: string,
+  ctx: PropResolutionContext,
+): string | undefined {
+  if (ctx.repeatBasePath == null) {
+    console.warn(`$bindItem used outside repeat scope: "${itemPath}"`);
+    return undefined;
+  }
+  if (itemPath === "") return ctx.repeatBasePath;
+  return ctx.repeatBasePath + "/" + itemPath;
+}
+
+// =============================================================================
+// Prop Expression Resolution
+// =============================================================================
+
 /**
  * Resolve a single prop value that may contain expressions.
- * Recursively resolves $path and $cond/$then/$else expressions.
+ * Handles $state, $item, $index, $bindState, $bindItem, and $cond/$then/$else in a single pass.
  */
 export function resolvePropValue(
   value: unknown,
-  ctx: VisibilityContext,
+  ctx: PropResolutionContext,
 ): unknown {
   if (value === null || value === undefined) {
     return value;
   }
 
-  // $path: read from state model
-  if (isPathExpression(value)) {
-    return getByPath(ctx.stateModel, value.$path);
+  // $state: read from global state model
+  if (isStateExpression(value)) {
+    return getByPath(ctx.stateModel, value.$state);
+  }
+
+  // $item: read from current repeat item
+  if (isItemExpression(value)) {
+    if (ctx.repeatItem === undefined) return undefined;
+    // "" means the whole item, "field" means a field on the item
+    return value.$item === ""
+      ? ctx.repeatItem
+      : getByPath(ctx.repeatItem, value.$item);
+  }
+
+  // $index: return current repeat array index
+  if (isIndexExpression(value)) {
+    return ctx.repeatIndex;
+  }
+
+  // $bindState: two-way binding to global state path
+  if (isBindStateExpression(value)) {
+    return getByPath(ctx.stateModel, value.$bindState);
+  }
+
+  // $bindItem: two-way binding to repeat item field
+  if (isBindItemExpression(value)) {
+    const resolvedPath = resolveBindItemPath(value.$bindItem, ctx);
+    if (resolvedPath === undefined) return undefined;
+    return getByPath(ctx.stateModel, resolvedPath);
   }
 
   // $cond/$then/$else: evaluate condition and pick branch
@@ -100,7 +217,7 @@ export function resolvePropValue(
  */
 export function resolveElementProps(
   props: Record<string, unknown>,
-  ctx: VisibilityContext,
+  ctx: PropResolutionContext,
 ): Record<string, unknown> {
   const resolved: Record<string, unknown> = {};
   for (const [key, value] of Object.entries(props)) {
@@ -108,3 +225,63 @@ export function resolveElementProps(
   }
   return resolved;
 }
+
+/**
+ * Scan an element's raw props for `$bindState` / `$bindItem` expressions
+ * and return a map of prop name → resolved absolute state path.
+ *
+ * This is called **before** `resolveElementProps` so the component can
+ * receive both the resolved value (in `props`) and the write-back path
+ * (in `bindings`).
+ *
+ * @example
+ * ```ts
+ * const rawProps = { value: { $bindState: "/form/email" }, label: "Email" };
+ * const bindings = resolveBindings(rawProps, ctx);
+ * // bindings = { value: "/form/email" }
+ * ```
+ */
+export function resolveBindings(
+  props: Record<string, unknown>,
+  ctx: PropResolutionContext,
+): Record<string, string> | undefined {
+  let bindings: Record<string, string> | undefined;
+  for (const [key, value] of Object.entries(props)) {
+    if (isBindStateExpression(value)) {
+      if (!bindings) bindings = {};
+      bindings[key] = value.$bindState;
+    } else if (isBindItemExpression(value)) {
+      const resolved = resolveBindItemPath(value.$bindItem, ctx);
+      if (resolved !== undefined) {
+        if (!bindings) bindings = {};
+        bindings[key] = resolved;
+      }
+    }
+  }
+  return bindings;
+}
+
+/**
+ * Resolve a single action parameter value.
+ *
+ * Like {@link resolvePropValue} but with special handling for path-valued
+ * params: `{ $item: "field" }` resolves to an **absolute state path**
+ * (e.g. `/todos/0/field`) instead of the field's value, so the path can
+ * be passed to `setState` / `pushState` / `removeState`.
+ *
+ * - `{ $item: "field" }` → absolute state path via `repeatBasePath`
+ * - `{ $index: true }` → current repeat index (number)
+ * - Everything else delegates to `resolvePropValue` ($state, $cond, literals).
+ */
+export function resolveActionParam(
+  value: unknown,
+  ctx: PropResolutionContext,
+): unknown {
+  if (isItemExpression(value)) {
+    return resolveBindItemPath(value.$item, ctx);
+  }
+  if (isIndexExpression(value)) {
+    return ctx.repeatIndex;
+  }
+  return resolvePropValue(value, ctx);
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов