Chris Tate před 5 měsíci
revize
7663fb22b0
100 změnil soubory, kde provedl 9503 přidání a 0 odebrání
  1. 41 0
      .gitignore
  2. 0 0
      .npmrc
  3. 7 0
      .vscode/settings.json
  4. 30 0
      AGENTS.md
  5. 228 0
      README.md
  6. 80 0
      apps/dashboard/app/api/generate/route.ts
  7. 28 0
      apps/dashboard/app/layout.tsx
  8. 317 0
      apps/dashboard/app/page.tsx
  9. 716 0
      apps/dashboard/components/ui-components.tsx
  10. 192 0
      apps/dashboard/lib/catalog.ts
  11. 6 0
      apps/dashboard/next.config.js
  12. 27 0
      apps/dashboard/package.json
  13. 11 0
      apps/dashboard/tsconfig.json
  14. 28 0
      apps/docs/package.json
  15. 36 0
      apps/web/.gitignore
  16. 36 0
      apps/web/README.md
  17. 151 0
      apps/web/app/docs/actions/page.tsx
  18. 119 0
      apps/web/app/docs/ai-sdk/page.tsx
  19. 110 0
      apps/web/app/docs/api/core/page.tsx
  20. 110 0
      apps/web/app/docs/api/react/page.tsx
  21. 101 0
      apps/web/app/docs/catalog/page.tsx
  22. 104 0
      apps/web/app/docs/components/page.tsx
  23. 125 0
      apps/web/app/docs/data-binding/page.tsx
  24. 53 0
      apps/web/app/docs/installation/page.tsx
  25. 77 0
      apps/web/app/docs/layout.tsx
  26. 64 0
      apps/web/app/docs/page.tsx
  27. 172 0
      apps/web/app/docs/quick-start/page.tsx
  28. 119 0
      apps/web/app/docs/streaming/page.tsx
  29. 147 0
      apps/web/app/docs/validation/page.tsx
  30. 136 0
      apps/web/app/docs/visibility/page.tsx
  31. binární
      apps/web/app/favicon.ico
  32. binární
      apps/web/app/fonts/GeistMonoVF.woff
  33. binární
      apps/web/app/fonts/GeistVF.woff
  34. 102 0
      apps/web/app/globals.css
  35. 38 0
      apps/web/app/layout.tsx
  36. 172 0
      apps/web/app/page.tsx
  37. 72 0
      apps/web/app/playground/page.tsx
  38. 22 0
      apps/web/components.json
  39. 94 0
      apps/web/components/code-block.tsx
  40. 79 0
      apps/web/components/code.tsx
  41. 250 0
      apps/web/components/demo.tsx
  42. 24 0
      apps/web/components/footer.tsx
  43. 35 0
      apps/web/components/header.tsx
  44. 46 0
      apps/web/components/ui/badge.tsx
  45. 62 0
      apps/web/components/ui/button.tsx
  46. 92 0
      apps/web/components/ui/card.tsx
  47. 66 0
      apps/web/components/ui/tabs.tsx
  48. 4 0
      apps/web/eslint.config.js
  49. 6 0
      apps/web/lib/utils.ts
  50. 4 0
      apps/web/next.config.js
  51. 43 0
      apps/web/package.json
  52. 5 0
      apps/web/postcss.config.mjs
  53. 24 0
      apps/web/tsconfig.json
  54. 80 0
      examples/dashboard/app/api/generate/route.ts
  55. 22 0
      examples/dashboard/app/globals.css
  56. 28 0
      examples/dashboard/app/layout.tsx
  57. 317 0
      examples/dashboard/app/page.tsx
  58. 716 0
      examples/dashboard/components/ui-components.tsx
  59. 192 0
      examples/dashboard/lib/catalog.ts
  60. 6 0
      examples/dashboard/next-env.d.ts
  61. 6 0
      examples/dashboard/next.config.js
  62. 27 0
      examples/dashboard/package.json
  63. 11 0
      examples/dashboard/tsconfig.json
  64. 20 0
      package.json
  65. 184 0
      packages/core/README.md
  66. 34 0
      packages/core/package.json
  67. 243 0
      packages/core/src/actions.ts
  68. 259 0
      packages/core/src/catalog.ts
  69. 96 0
      packages/core/src/index.ts
  70. 209 0
      packages/core/src/types.ts
  71. 334 0
      packages/core/src/validation.ts
  72. 231 0
      packages/core/src/visibility.ts
  73. 9 0
      packages/core/tsconfig.json
  74. 10 0
      packages/core/tsup.config.ts
  75. 3 0
      packages/eslint-config/README.md
  76. 32 0
      packages/eslint-config/base.js
  77. 57 0
      packages/eslint-config/next.js
  78. 24 0
      packages/eslint-config/package.json
  79. 39 0
      packages/eslint-config/react-internal.js
  80. 238 0
      packages/react/README.md
  81. 35 0
      packages/react/package.json
  82. 304 0
      packages/react/src/contexts/actions.tsx
  83. 137 0
      packages/react/src/contexts/data.tsx
  84. 215 0
      packages/react/src/contexts/validation.tsx
  85. 79 0
      packages/react/src/contexts/visibility.tsx
  86. 254 0
      packages/react/src/hooks.ts
  87. 57 0
      packages/react/src/index.ts
  88. 222 0
      packages/react/src/renderer.tsx
  89. 9 0
      packages/react/tsconfig.json
  90. 10 0
      packages/react/tsup.config.ts
  91. 19 0
      packages/typescript-config/base.json
  92. 12 0
      packages/typescript-config/nextjs.json
  93. 9 0
      packages/typescript-config/package.json
  94. 7 0
      packages/typescript-config/react-library.json
  95. 4 0
      packages/ui/eslint.config.mjs
  96. 26 0
      packages/ui/package.json
  97. 20 0
      packages/ui/src/button.tsx
  98. 27 0
      packages/ui/src/card.tsx
  99. 11 0
      packages/ui/src/code.tsx
  100. 8 0
      packages/ui/tsconfig.json

+ 41 - 0
.gitignore

@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# Dependencies
+node_modules
+.pnp
+.pnp.js
+
+# Local env files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# Testing
+coverage
+
+# Turbo
+.turbo
+
+# Vercel
+.vercel
+
+# Build Outputs
+.next/
+out/
+build
+dist
+
+
+# Debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Misc
+.DS_Store
+*.pem
+
+# opensrc - source code for packages
+opensrc/

+ 0 - 0
.npmrc


+ 7 - 0
.vscode/settings.json

@@ -0,0 +1,7 @@
+{
+  "eslint.workingDirectories": [
+    {
+      "mode": "auto"
+    }
+  ]
+}

+ 30 - 0
AGENTS.md

@@ -0,0 +1,30 @@
+# AGENTS.md
+
+Instructions for AI coding agents working with this codebase.
+
+## Code Style
+
+- Do not use emojis in code or UI
+
+<!-- opensrc:start -->
+
+## Source Code Reference
+
+Source code for dependencies is available in `opensrc/` for deeper understanding of implementation details.
+
+See `opensrc/sources.json` for the list of available packages and their versions.
+
+Use this source code when you need to understand how a package works internally, not just its types/interface.
+
+### Fetching Additional Source Code
+
+To fetch source code for a package or repository you need to understand, run:
+
+```bash
+npx opensrc <package>           # npm package (e.g., npx opensrc zod)
+npx opensrc pypi:<package>      # Python package (e.g., npx opensrc pypi:requests)
+npx opensrc crates:<package>    # Rust crate (e.g., npx opensrc crates:serde)
+npx opensrc <owner>/<repo>      # GitHub repo (e.g., npx opensrc vercel/ai)
+```
+
+<!-- opensrc:end -->

+ 228 - 0
README.md

@@ -0,0 +1,228 @@
+# json-render
+
+**JSON becomes real things.** Define your catalog, register your components, let AI generate.
+
+```bash
+npm install @json-render/core @json-render/react
+```
+
+## Why json-render?
+
+Unlike vibe-coding tools that generate arbitrary code, json-render gives AI a **constrained vocabulary**. Perfect for enterprise apps where you need:
+
+- **Consistency** — AI only uses your approved components
+- **Safety** — Actions are declared by name, you control what they do
+- **Flexibility** — Works for UI, backend, anything
+
+## Quick Start
+
+### 1. Define Your Catalog (what AI can use)
+
+```typescript
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+const catalog = createCatalog({
+  components: {
+    Card: {
+      props: z.object({ title: z.string() }),
+      hasChildren: true,
+    },
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(),      // Binds to your data
+        format: z.enum(['currency', 'percent', 'number']),
+      }),
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        action: ActionSchema,        // AI declares intent, you handle it
+      }),
+    },
+  },
+  actions: {
+    export_report: { description: 'Export dashboard to PDF' },
+    refresh_data: { description: 'Refresh all metrics' },
+  },
+});
+```
+
+### 2. Register Your Components (how they render)
+
+```tsx
+const registry = {
+  Card: ({ element, children }) => (
+    <div className="card">
+      <h3>{element.props.title}</h3>
+      {children}
+    </div>
+  ),
+  Metric: ({ element }) => {
+    const value = useDataValue(element.props.valuePath);
+    return <div className="metric">{format(value)}</div>;
+  },
+  Button: ({ element, onAction }) => (
+    <button onClick={() => onAction(element.props.action)}>
+      {element.props.label}
+    </button>
+  ),
+};
+```
+
+### 3. Let AI Generate
+
+```tsx
+import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
+
+function Dashboard() {
+  const { tree, send } = useUIStream({ api: '/api/generate' });
+
+  return (
+    <DataProvider initialData={{ revenue: 125000, growth: 0.15 }}>
+      <ActionProvider actions={{
+        export_report: () => downloadPDF(),
+        refresh_data: () => refetch(),
+      }}>
+        <input
+          placeholder="Create a revenue dashboard..."
+          onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
+        />
+        <Renderer tree={tree} components={registry} />
+      </ActionProvider>
+    </DataProvider>
+  );
+}
+```
+
+**That's it.** AI generates JSON, you render it safely.
+
+---
+
+## Features
+
+### Conditional Visibility
+
+Show/hide components based on data, auth, or complex logic:
+
+```json
+{
+  "type": "Alert",
+  "props": { "message": "Error occurred" },
+  "visible": {
+    "and": [
+      { "path": "/form/hasError" },
+      { "not": { "path": "/form/errorDismissed" } }
+    ]
+  }
+}
+```
+
+```json
+{
+  "type": "AdminPanel",
+  "visible": { "auth": "signedIn" }
+}
+```
+
+### Rich Actions
+
+Actions with confirmation dialogs and callbacks:
+
+```json
+{
+  "type": "Button",
+  "props": {
+    "label": "Refund Payment",
+    "action": {
+      "name": "refund",
+      "params": {
+        "paymentId": { "path": "/selected/id" },
+        "amount": { "path": "/refund/amount" }
+      },
+      "confirm": {
+        "title": "Confirm Refund",
+        "message": "Refund ${/refund/amount} to customer?",
+        "variant": "danger"
+      },
+      "onSuccess": { "set": { "/ui/success": true } },
+      "onError": { "set": { "/ui/error": "$error.message" } }
+    }
+  }
+}
+```
+
+### Built-in Validation
+
+```json
+{
+  "type": "TextField",
+  "props": {
+    "label": "Email",
+    "valuePath": "/form/email",
+    "checks": [
+      { "fn": "required", "message": "Email is required" },
+      { "fn": "email", "message": "Invalid email" }
+    ],
+    "validateOn": "blur"
+  }
+}
+```
+
+---
+
+## Packages
+
+| Package | Description |
+|---------|-------------|
+| `@json-render/core` | Types, schemas, visibility, actions, validation |
+| `@json-render/react` | React renderer, providers, hooks |
+
+## Demo
+
+```bash
+git clone https://github.com/vercel-labs/json-render
+cd json-render
+pnpm install
+pnpm dev
+```
+
+- http://localhost:3000 — Docs & Playground
+- http://localhost:3001 — Example Dashboard
+
+## Project Structure
+
+```
+json-render/
+├── packages/
+│   ├── core/        → @json-render/core
+│   └── react/       → @json-render/react
+├── apps/
+│   └── web/         → Docs & Playground site
+└── examples/
+    └── dashboard/   → Example dashboard app
+```
+
+## How It Works
+
+```
+┌─────────────┐     ┌──────────────┐     ┌─────────────┐
+│   Prompt    │────▶│  AI + Catalog│────▶│  JSON Tree  │
+│ "dashboard" │     │  (constrained)    │  (safe)     │
+└─────────────┘     └──────────────┘     └─────────────┘
+                                               │
+                    ┌──────────────┐            │
+                    │  Your React  │◀───────────┘
+                    │  Components  │
+                    └──────────────┘
+```
+
+1. **You define the catalog** — what components exist, what props they take
+2. **AI generates JSON** — constrained to your catalog
+3. **You render it** — with your own components
+4. **Actions are safe** — AI declares intent (`"refund"`), you implement it
+
+## License
+
+MIT

+ 80 - 0
apps/dashboard/app/api/generate/route.ts

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

+ 28 - 0
apps/dashboard/app/layout.tsx

@@ -0,0 +1,28 @@
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+  title: 'json-render Dashboard Demo',
+  description: 'AI-powered dashboard widget generator with guardrails',
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body
+        style={{
+          margin: 0,
+          fontFamily:
+            '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+          backgroundColor: '#f9fafb',
+          minHeight: '100vh',
+        }}
+      >
+        {children}
+      </body>
+    </html>
+  );
+}

+ 317 - 0
apps/dashboard/app/page.tsx

@@ -0,0 +1,317 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import {
+  DataProvider,
+  ActionProvider,
+  VisibilityProvider,
+  useUIStream,
+  Renderer,
+} from '@json-render/react';
+import { componentRegistry } from '@/components/ui-components';
+
+// Sample data for the dashboard
+const INITIAL_DATA = {
+  analytics: {
+    revenue: 125000,
+    growth: 0.15,
+    customers: 1234,
+    orders: 567,
+    salesByRegion: [
+      { label: 'US', value: 45000 },
+      { label: 'EU', value: 35000 },
+      { label: 'Asia', value: 28000 },
+      { label: 'Other', value: 17000 },
+    ],
+    recentTransactions: [
+      { id: 'TXN001', customer: 'Acme Corp', amount: 1500, status: 'completed', date: '2024-01-15' },
+      { id: 'TXN002', customer: 'Globex Inc', amount: 2300, status: 'pending', date: '2024-01-14' },
+      { id: 'TXN003', customer: 'Initech', amount: 890, status: 'completed', date: '2024-01-13' },
+      { id: 'TXN004', customer: 'Umbrella Co', amount: 4200, status: 'completed', date: '2024-01-12' },
+    ],
+  },
+  form: {
+    dateRange: '',
+    region: '',
+  },
+};
+
+// Action handlers
+const ACTION_HANDLERS = {
+  export_report: () => {
+    alert('Exporting report... (demo)');
+  },
+  refresh_data: () => {
+    alert('Refreshing data... (demo)');
+  },
+  view_details: (params: Record<string, unknown>) => {
+    alert(`Viewing details for: ${JSON.stringify(params)}`);
+  },
+  apply_filter: () => {
+    alert('Applying filters... (demo)');
+  },
+};
+
+function DashboardContent() {
+  const [prompt, setPrompt] = useState('');
+
+  const {
+    tree,
+    isStreaming,
+    error,
+    send,
+    clear,
+  } = useUIStream({
+    api: '/api/generate',
+    onError: (err) => console.error('Generation error:', err),
+  });
+
+  const handleSubmit = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+      if (!prompt.trim()) return;
+      await send(prompt, { data: INITIAL_DATA });
+    },
+    [prompt, send]
+  );
+
+  const examplePrompts = [
+    'Show me a revenue dashboard with key metrics and a chart of sales by region',
+    'Create a transactions table with recent orders',
+    'Build a widget showing customer count with a trend indicator',
+    'Make a simple card with total revenue and an export button',
+  ];
+
+  const hasElements = tree && Object.keys(tree.elements).length > 0;
+
+  return (
+    <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
+      {/* Header */}
+      <div style={{ marginBottom: '32px' }}>
+        <h1
+          style={{
+            margin: '0 0 8px 0',
+            fontSize: '28px',
+            fontWeight: 700,
+            color: '#111827',
+          }}
+        >
+          json-render Dashboard Demo
+        </h1>
+        <p style={{ margin: 0, color: '#6b7280', fontSize: '16px' }}>
+          AI-powered widget generation with guardrails. Only catalog components can be generated.
+        </p>
+      </div>
+
+      {/* Input Form */}
+      <div
+        style={{
+          backgroundColor: '#fff',
+          borderRadius: '12px',
+          border: '1px solid #e5e7eb',
+          padding: '20px',
+          marginBottom: '24px',
+        }}
+      >
+        <form onSubmit={handleSubmit}>
+          <div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
+            <input
+              type="text"
+              value={prompt}
+              onChange={(e) => setPrompt(e.target.value)}
+              placeholder="Describe the widget you want to create..."
+              style={{
+                flex: 1,
+                padding: '12px 16px',
+                borderRadius: '8px',
+                border: '1px solid #d1d5db',
+                fontSize: '15px',
+                outline: 'none',
+              }}
+              disabled={isStreaming}
+            />
+            <button
+              type="submit"
+              disabled={isStreaming || !prompt.trim()}
+              style={{
+                padding: '12px 24px',
+                backgroundColor: isStreaming ? '#9ca3af' : '#6366f1',
+                color: '#fff',
+                border: 'none',
+                borderRadius: '8px',
+                fontSize: '15px',
+                fontWeight: 500,
+                cursor: isStreaming ? 'not-allowed' : 'pointer',
+              }}
+            >
+              {isStreaming ? 'Generating...' : 'Generate'}
+            </button>
+            <button
+              type="button"
+              onClick={clear}
+              style={{
+                padding: '12px 20px',
+                backgroundColor: '#fff',
+                color: '#374151',
+                border: '1px solid #d1d5db',
+                borderRadius: '8px',
+                fontSize: '15px',
+                fontWeight: 500,
+                cursor: 'pointer',
+              }}
+            >
+              Clear
+            </button>
+          </div>
+
+          {/* Example Prompts */}
+          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
+            <span style={{ fontSize: '13px', color: '#6b7280', marginRight: '4px' }}>
+              Try:
+            </span>
+            {examplePrompts.map((example, i) => (
+              <button
+                key={i}
+                type="button"
+                onClick={() => setPrompt(example)}
+                style={{
+                  padding: '4px 10px',
+                  backgroundColor: '#f3f4f6',
+                  border: 'none',
+                  borderRadius: '4px',
+                  fontSize: '12px',
+                  color: '#4b5563',
+                  cursor: 'pointer',
+                }}
+              >
+                {example.slice(0, 40)}...
+              </button>
+            ))}
+          </div>
+        </form>
+      </div>
+
+      {/* Error Display */}
+      {error && (
+        <div
+          style={{
+            padding: '16px',
+            backgroundColor: '#fef2f2',
+            border: '1px solid #fca5a5',
+            borderRadius: '8px',
+            marginBottom: '24px',
+            color: '#991b1b',
+          }}
+        >
+          <strong>Error:</strong> {error.message}
+        </div>
+      )}
+
+      {/* Generated UI */}
+      <div
+        style={{
+          backgroundColor: '#fff',
+          borderRadius: '12px',
+          border: '1px solid #e5e7eb',
+          padding: '24px',
+          minHeight: '300px',
+        }}
+      >
+        {!hasElements && !isStreaming ? (
+          <div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
+            <div style={{ fontSize: '48px', marginBottom: '16px' }}>🎨</div>
+            <p style={{ fontSize: '16px', margin: 0 }}>
+              Enter a prompt above to generate a dashboard widget
+            </p>
+          </div>
+        ) : tree ? (
+          <Renderer
+            tree={tree}
+            registry={componentRegistry}
+            loading={isStreaming}
+          />
+        ) : null}
+      </div>
+
+      {/* Debug: Show generated JSON */}
+      {hasElements && (
+        <details style={{ marginTop: '24px' }}>
+          <summary
+            style={{
+              cursor: 'pointer',
+              fontSize: '14px',
+              color: '#6b7280',
+              marginBottom: '8px',
+            }}
+          >
+            View Generated JSON
+          </summary>
+          <pre
+            style={{
+              backgroundColor: '#1f2937',
+              color: '#e5e7eb',
+              padding: '16px',
+              borderRadius: '8px',
+              overflow: 'auto',
+              fontSize: '12px',
+            }}
+          >
+            {JSON.stringify(tree, null, 2)}
+          </pre>
+        </details>
+      )}
+
+      {/* Info Box */}
+      <div
+        style={{
+          marginTop: '32px',
+          padding: '20px',
+          backgroundColor: '#eff6ff',
+          borderRadius: '8px',
+          border: '1px solid #bfdbfe',
+        }}
+      >
+        <h3 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1e40af' }}>
+          How json-render Works
+        </h3>
+        <ul
+          style={{
+            margin: 0,
+            paddingLeft: '20px',
+            color: '#1e40af',
+            fontSize: '14px',
+            lineHeight: 1.6,
+          }}
+        >
+          <li>
+            <strong>Guardrails:</strong> AI can only generate components from the catalog (Card,
+            Metric, Chart, etc.)
+          </li>
+          <li>
+            <strong>Data Binding:</strong> Components reference data paths like{' '}
+            <code>/analytics/revenue</code>
+          </li>
+          <li>
+            <strong>Named Actions:</strong> Buttons declare actions like{' '}
+            <code>export_report</code> - you control what they do
+          </li>
+          <li>
+            <strong>Your Design System:</strong> The catalog maps to your actual React components
+          </li>
+        </ul>
+      </div>
+    </div>
+  );
+}
+
+export default function DashboardPage() {
+  return (
+    <DataProvider initialData={INITIAL_DATA}>
+      <VisibilityProvider>
+        <ActionProvider handlers={ACTION_HANDLERS}>
+          <DashboardContent />
+        </ActionProvider>
+      </VisibilityProvider>
+    </DataProvider>
+  );
+}

+ 716 - 0
apps/dashboard/components/ui-components.tsx

@@ -0,0 +1,716 @@
+'use client';
+
+import React, { type ReactNode } from 'react';
+import { type ComponentRenderProps } from '@json-render/react';
+import { useData, useFieldValidation } from '@json-render/react';
+import { getByPath } from '@json-render/core';
+
+// Helper to get value from data model
+function useResolvedValue<T>(value: T | { path: string } | null | undefined): T | undefined {
+  const { data } = useData();
+  
+  if (value === null || value === undefined) {
+    return undefined;
+  }
+  
+  if (typeof value === 'object' && 'path' in value) {
+    return getByPath(data, value.path) as T | undefined;
+  }
+  
+  return value as T;
+}
+
+// Styles
+const styles = {
+  card: {
+    backgroundColor: '#fff',
+    borderRadius: '8px',
+    border: '1px solid #e5e7eb',
+    overflow: 'hidden',
+  },
+  cardHeader: {
+    padding: '16px 20px',
+    borderBottom: '1px solid #e5e7eb',
+  },
+  cardBody: (padding: string | null | undefined) => ({
+    padding:
+      padding === 'none' ? 0 :
+      padding === 'sm' ? '12px' :
+      padding === 'lg' ? '24px' : '16px',
+  }),
+  metric: {
+    display: 'flex',
+    flexDirection: 'column' as const,
+    gap: '4px',
+  },
+  metricLabel: {
+    fontSize: '14px',
+    color: '#6b7280',
+  },
+  metricValue: {
+    fontSize: '32px',
+    fontWeight: 600,
+    color: '#111827',
+  },
+  metricTrend: (trend: string | null | undefined) => ({
+    fontSize: '14px',
+    color: trend === 'up' ? '#10b981' : trend === 'down' ? '#ef4444' : '#6b7280',
+  }),
+};
+
+// Card component
+export function Card({ element, children }: ComponentRenderProps) {
+  const { title, description, padding } = element.props as {
+    title?: string | null;
+    description?: string | null;
+    padding?: string | null;
+  };
+
+  return (
+    <div style={styles.card}>
+      {(title || description) && (
+        <div style={styles.cardHeader}>
+          {title && (
+            <h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>{title}</h3>
+          )}
+          {description && (
+            <p style={{ margin: '4px 0 0', fontSize: '14px', color: '#6b7280' }}>
+              {description}
+            </p>
+          )}
+        </div>
+      )}
+      <div style={styles.cardBody(padding)}>{children}</div>
+    </div>
+  );
+}
+
+// Heading component
+export function Heading({ element }: ComponentRenderProps) {
+  const { text, level } = element.props as {
+    text: string;
+    level?: string | null;
+  };
+
+  const Tag = (level || 'h2') as keyof JSX.IntrinsicElements;
+  const sizes: Record<string, string> = {
+    h1: '28px',
+    h2: '24px',
+    h3: '20px',
+    h4: '16px',
+  };
+
+  return (
+    <Tag style={{ margin: '0 0 16px', fontSize: sizes[level || 'h2'], fontWeight: 600 }}>
+      {text}
+    </Tag>
+  );
+}
+
+// Text component
+export function Text({ element }: ComponentRenderProps) {
+  const { content, variant } = element.props as {
+    content: string;
+    variant?: string | null;
+  };
+
+  const colors: Record<string, string> = {
+    default: '#111827',
+    muted: '#6b7280',
+    success: '#10b981',
+    warning: '#f59e0b',
+    error: '#ef4444',
+  };
+
+  return (
+    <p style={{ margin: 0, color: colors[variant || 'default'] }}>{content}</p>
+  );
+}
+
+// Metric component
+export function Metric({ element }: ComponentRenderProps) {
+  const { label, valuePath, format, trend, trendValue } = element.props as {
+    label: string;
+    valuePath: string;
+    format?: string | null;
+    trend?: string | null;
+    trendValue?: string | null;
+  };
+
+  const { data } = useData();
+  const rawValue = getByPath(data, valuePath);
+
+  let displayValue = String(rawValue ?? '—');
+  if (format === 'currency' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US', {
+      style: 'currency',
+      currency: 'USD',
+    }).format(rawValue);
+  } else if (format === 'percent' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US', {
+      style: 'percent',
+      minimumFractionDigits: 1,
+    }).format(rawValue);
+  } else if (format === 'number' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US').format(rawValue);
+  }
+
+  return (
+    <div style={styles.metric}>
+      <span style={styles.metricLabel}>{label}</span>
+      <span style={styles.metricValue}>{displayValue}</span>
+      {(trend || trendValue) && (
+        <span style={styles.metricTrend(trend)}>
+          {trend === 'up' ? '↑' : trend === 'down' ? '↓' : ''} {trendValue}
+        </span>
+      )}
+    </div>
+  );
+}
+
+// Chart component (simplified placeholder)
+export function Chart({ element }: ComponentRenderProps) {
+  const { title, dataPath, type } = element.props as {
+    title?: string | null;
+    dataPath: string;
+    type?: string | null;
+  };
+
+  const { data } = useData();
+  const chartData = getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined;
+
+  if (!chartData || !Array.isArray(chartData)) {
+    return <div style={{ padding: '20px', color: '#6b7280' }}>No chart data</div>;
+  }
+
+  const maxValue = Math.max(...chartData.map((d) => d.value));
+
+  return (
+    <div>
+      {title && (
+        <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
+      )}
+      <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', height: '120px' }}>
+        {chartData.map((d, i) => (
+          <div
+            key={i}
+            style={{
+              flex: 1,
+              display: 'flex',
+              flexDirection: 'column',
+              alignItems: 'center',
+              gap: '4px',
+            }}
+          >
+            <div
+              style={{
+                width: '100%',
+                height: `${(d.value / maxValue) * 100}%`,
+                backgroundColor: '#6366f1',
+                borderRadius: '4px 4px 0 0',
+                minHeight: '4px',
+              }}
+            />
+            <span style={{ fontSize: '12px', color: '#6b7280' }}>{d.label}</span>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// Table component
+export function Table({ element }: ComponentRenderProps) {
+  const { title, dataPath, columns } = element.props as {
+    title?: string | null;
+    dataPath: string;
+    columns: Array<{ key: string; label: string; format?: string | null }>;
+  };
+
+  const { data } = useData();
+  const tableData = getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined;
+
+  if (!tableData || !Array.isArray(tableData)) {
+    return <div style={{ padding: '20px', color: '#6b7280' }}>No data</div>;
+  }
+
+  const formatCell = (value: unknown, format?: string | null) => {
+    if (value === null || value === undefined) return '—';
+    if (format === 'currency' && typeof value === 'number') {
+      return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
+    }
+    if (format === 'date' && typeof value === 'string') {
+      return new Date(value).toLocaleDateString();
+    }
+    if (format === 'badge') {
+      const colors: Record<string, { bg: string; text: string }> = {
+        completed: { bg: '#d1fae5', text: '#065f46' },
+        pending: { bg: '#fef3c7', text: '#92400e' },
+        failed: { bg: '#fee2e2', text: '#991b1b' },
+      };
+      const style = colors[String(value).toLowerCase()] || { bg: '#f3f4f6', text: '#374151' };
+      return (
+        <span
+          style={{
+            padding: '2px 8px',
+            borderRadius: '12px',
+            fontSize: '12px',
+            fontWeight: 500,
+            backgroundColor: style.bg,
+            color: style.text,
+          }}
+        >
+          {String(value)}
+        </span>
+      );
+    }
+    return String(value);
+  };
+
+  return (
+    <div>
+      {title && (
+        <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
+      )}
+      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
+        <thead>
+          <tr>
+            {columns.map((col) => (
+              <th
+                key={col.key}
+                style={{
+                  textAlign: 'left',
+                  padding: '12px 8px',
+                  borderBottom: '1px solid #e5e7eb',
+                  fontSize: '12px',
+                  fontWeight: 600,
+                  color: '#6b7280',
+                  textTransform: 'uppercase',
+                }}
+              >
+                {col.label}
+              </th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {tableData.map((row, i) => (
+            <tr key={i}>
+              {columns.map((col) => (
+                <td
+                  key={col.key}
+                  style={{
+                    padding: '12px 8px',
+                    borderBottom: '1px solid #e5e7eb',
+                    fontSize: '14px',
+                  }}
+                >
+                  {formatCell(row[col.key], col.format)}
+                </td>
+              ))}
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}
+
+// Button component
+export function Button({ element, onAction, loading }: ComponentRenderProps) {
+  const { label, variant, action, disabled } = element.props as {
+    label: string;
+    variant?: string | null;
+    action: { name: string };
+    disabled?: boolean | null;
+  };
+
+  const variants: Record<string, React.CSSProperties> = {
+    primary: { backgroundColor: '#6366f1', color: '#fff', border: 'none' },
+    secondary: { backgroundColor: '#fff', color: '#374151', border: '1px solid #d1d5db' },
+    danger: { backgroundColor: '#dc2626', color: '#fff', border: 'none' },
+    ghost: { backgroundColor: 'transparent', color: '#6b7280', border: 'none' },
+  };
+
+  const handleClick = () => {
+    if (!disabled && action && onAction) {
+      onAction(action);
+    }
+  };
+
+  return (
+    <button
+      onClick={handleClick}
+      disabled={!!disabled || loading}
+      style={{
+        padding: '8px 16px',
+        borderRadius: '6px',
+        fontSize: '14px',
+        fontWeight: 500,
+        cursor: disabled ? 'not-allowed' : 'pointer',
+        opacity: disabled ? 0.5 : 1,
+        ...variants[variant || 'primary'],
+      }}
+    >
+      {loading ? 'Loading...' : label}
+    </button>
+  );
+}
+
+// Alert component
+export function Alert({ element }: ComponentRenderProps) {
+  const { message, variant, dismissible } = element.props as {
+    message: string | { path: string };
+    variant?: string | null;
+    dismissible?: boolean | null;
+  };
+
+  const resolvedMessage = useResolvedValue(message);
+
+  const defaultStyle = { bg: '#eff6ff', border: '#bfdbfe', text: '#1e40af' };
+  const variants: Record<string, { bg: string; border: string; text: string }> = {
+    info: defaultStyle,
+    success: { bg: '#d1fae5', border: '#a7f3d0', text: '#065f46' },
+    warning: { bg: '#fef3c7', border: '#fcd34d', text: '#92400e' },
+    error: { bg: '#fee2e2', border: '#fca5a5', text: '#991b1b' },
+  };
+
+  const style = variants[variant || 'info'] ?? defaultStyle;
+
+  return (
+    <div
+      style={{
+        padding: '12px 16px',
+        borderRadius: '8px',
+        backgroundColor: style.bg,
+        border: `1px solid ${style.border}`,
+        color: style.text,
+        fontSize: '14px',
+      }}
+    >
+      {resolvedMessage}
+    </div>
+  );
+}
+
+// Grid component
+export function Grid({ element, children }: ComponentRenderProps) {
+  const { columns, gap } = element.props as {
+    columns?: number | null;
+    gap?: string | null;
+  };
+
+  const gaps: Record<string, string> = {
+    none: '0',
+    sm: '8px',
+    md: '16px',
+    lg: '24px',
+  };
+
+  return (
+    <div
+      style={{
+        display: 'grid',
+        gridTemplateColumns: `repeat(${columns || 2}, 1fr)`,
+        gap: gaps[gap || 'md'],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+
+// Stack component
+export function Stack({ element, children }: ComponentRenderProps) {
+  const { direction, gap, align } = element.props as {
+    direction?: string | null;
+    gap?: string | null;
+    align?: string | null;
+  };
+
+  const gaps: Record<string, string> = {
+    none: '0',
+    sm: '8px',
+    md: '16px',
+    lg: '24px',
+  };
+
+  const alignments: Record<string, string> = {
+    start: 'flex-start',
+    center: 'center',
+    end: 'flex-end',
+    stretch: 'stretch',
+  };
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        flexDirection: direction === 'horizontal' ? 'row' : 'column',
+        gap: gaps[gap || 'md'],
+        alignItems: alignments[align || 'stretch'],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+
+// Divider component
+export function Divider({ element }: ComponentRenderProps) {
+  const { orientation } = element.props as {
+    orientation?: string | null;
+  };
+
+  if (orientation === 'vertical') {
+    return (
+      <div
+        style={{
+          width: '1px',
+          backgroundColor: '#e5e7eb',
+          alignSelf: 'stretch',
+        }}
+      />
+    );
+  }
+
+  return (
+    <hr
+      style={{
+        border: 'none',
+        borderTop: '1px solid #e5e7eb',
+        margin: '16px 0',
+      }}
+    />
+  );
+}
+
+// Badge component
+export function Badge({ element }: ComponentRenderProps) {
+  const { text, variant } = element.props as {
+    text: string | { path: string };
+    variant?: string | null;
+  };
+
+  const resolvedText = useResolvedValue(text);
+
+  const defaultBadgeStyle = { bg: '#f3f4f6', text: '#374151' };
+  const variants: Record<string, { bg: string; text: string }> = {
+    default: defaultBadgeStyle,
+    success: { bg: '#d1fae5', text: '#065f46' },
+    warning: { bg: '#fef3c7', text: '#92400e' },
+    error: { bg: '#fee2e2', text: '#991b1b' },
+    info: { bg: '#dbeafe', text: '#1e40af' },
+  };
+
+  const style = variants[variant || 'default'] ?? defaultBadgeStyle;
+
+  return (
+    <span
+      style={{
+        display: 'inline-block',
+        padding: '2px 8px',
+        borderRadius: '12px',
+        fontSize: '12px',
+        fontWeight: 500,
+        backgroundColor: style.bg,
+        color: style.text,
+      }}
+    >
+      {resolvedText}
+    </span>
+  );
+}
+
+// TextField component
+export function TextField({ element }: ComponentRenderProps) {
+  const { label, valuePath, placeholder, type, checks, validateOn } = element.props as {
+    label: string;
+    valuePath: string;
+    placeholder?: string | null;
+    type?: string | null;
+    checks?: Array<{ fn: string; message: string }> | null;
+    validateOn?: string | null;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+  
+  const { errors, validate, touch } = useFieldValidation(valuePath, {
+    checks: checks ?? undefined,
+    validateOn: (validateOn as 'change' | 'blur' | 'submit') ?? 'blur',
+  });
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    set(valuePath, e.target.value);
+    if (validateOn === 'change') {
+      validate();
+    }
+  };
+
+  const handleBlur = () => {
+    touch();
+    if (validateOn === 'blur' || !validateOn) {
+      validate();
+    }
+  };
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <input
+        type={type || 'text'}
+        value={value ?? ''}
+        onChange={handleChange}
+        onBlur={handleBlur}
+        placeholder={placeholder ?? ''}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: errors.length > 0 ? '1px solid #ef4444' : '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+        }}
+      />
+      {errors.map((error, i) => (
+        <span key={i} style={{ fontSize: '12px', color: '#ef4444' }}>
+          {error}
+        </span>
+      ))}
+    </div>
+  );
+}
+
+// Select component
+export function Select({ element }: ComponentRenderProps) {
+  const { label, valuePath, options, placeholder } = element.props as {
+    label: string;
+    valuePath: string;
+    options: Array<{ value: string; label: string }>;
+    placeholder?: string | null;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <select
+        value={value ?? ''}
+        onChange={(e) => set(valuePath, e.target.value)}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+          backgroundColor: '#fff',
+        }}
+      >
+        {placeholder && <option value="">{placeholder}</option>}
+        {options.map((opt) => (
+          <option key={opt.value} value={opt.value}>
+            {opt.label}
+          </option>
+        ))}
+      </select>
+    </div>
+  );
+}
+
+// DatePicker component (simplified)
+export function DatePicker({ element }: ComponentRenderProps) {
+  const { label, valuePath } = element.props as {
+    label: string;
+    valuePath: string;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <input
+        type="date"
+        value={value ?? ''}
+        onChange={(e) => set(valuePath, e.target.value)}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+        }}
+      />
+    </div>
+  );
+}
+
+// Empty component
+export function Empty({ element }: ComponentRenderProps) {
+  const { icon, title, description } = element.props as {
+    icon?: string | null;
+    title: string;
+    description?: string | null;
+  };
+
+  return (
+    <div style={{ textAlign: 'center', padding: '40px 20px' }}>
+      {icon && <div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>}
+      <h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#111827' }}>
+        {title}
+      </h3>
+      {description && (
+        <p style={{ margin: 0, fontSize: '14px', color: '#6b7280' }}>{description}</p>
+      )}
+    </div>
+  );
+}
+
+// List component
+export function List({ element, children }: ComponentRenderProps) {
+  const { dataPath } = element.props as {
+    dataPath: string;
+    itemKey?: string | null;
+  };
+
+  const { data } = useData();
+  const listData = getByPath(data, dataPath) as Array<unknown> | undefined;
+
+  if (!listData || !Array.isArray(listData)) {
+    return <div style={{ color: '#6b7280' }}>No items</div>;
+  }
+
+  // For now, just render children
+  // In a full implementation, children would be cloned for each item
+  return <div>{children}</div>;
+}
+
+// Export all components as a registry
+export const componentRegistry = {
+  Card,
+  Heading,
+  Text,
+  Metric,
+  Chart,
+  Table,
+  Button,
+  Alert,
+  Grid,
+  Stack,
+  Divider,
+  Badge,
+  TextField,
+  Select,
+  DatePicker,
+  Empty,
+  List,
+};

+ 192 - 0
apps/dashboard/lib/catalog.ts

@@ -0,0 +1,192 @@
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+/**
+ * Dashboard component catalog
+ *
+ * This defines the ONLY components that the AI can generate.
+ * It acts as a guardrail - the AI cannot create arbitrary HTML/CSS.
+ * 
+ * Note: OpenAI structured output requires all fields to be required.
+ * Use .nullable() instead of .optional() for optional fields.
+ */
+export const dashboardCatalog = createCatalog({
+  name: 'dashboard',
+  components: {
+    // Layout Components
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+        padding: z.enum(['sm', 'md', 'lg']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'A card container with optional title',
+    },
+
+    Grid: {
+      props: z.object({
+        columns: z.number().min(1).max(4).nullable(),
+        gap: z.enum(['sm', 'md', 'lg']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'Grid layout with configurable columns',
+    },
+
+    Stack: {
+      props: z.object({
+        direction: z.enum(['horizontal', 'vertical']).nullable(),
+        gap: z.enum(['sm', 'md', 'lg']).nullable(),
+        align: z.enum(['start', 'center', 'end', 'stretch']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'Flex stack for horizontal or vertical layouts',
+    },
+
+    // Data Display Components
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(),
+        format: z.enum(['number', 'currency', 'percent']).nullable(),
+        trend: z.enum(['up', 'down', 'neutral']).nullable(),
+        trendValue: z.string().nullable(),
+      }),
+      description: 'Display a single metric with optional trend indicator',
+    },
+
+    Chart: {
+      props: z.object({
+        type: z.enum(['bar', 'line', 'pie', 'area']),
+        dataPath: z.string(),
+        title: z.string().nullable(),
+        height: z.number().nullable(),
+      }),
+      description: 'Display a chart from array data',
+    },
+
+    Table: {
+      props: z.object({
+        dataPath: z.string(),
+        columns: z.array(
+          z.object({
+            key: z.string(),
+            label: z.string(),
+            format: z.enum(['text', 'currency', 'date', 'badge']).nullable(),
+          })
+        ),
+      }),
+      description: 'Display tabular data',
+    },
+
+    List: {
+      props: z.object({
+        dataPath: z.string(),
+        emptyMessage: z.string().nullable(),
+      }),
+      hasChildren: true,
+      description: 'Render a list from array data',
+    },
+
+    // Interactive Components
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(['primary', 'secondary', 'danger', 'ghost']).nullable(),
+        size: z.enum(['sm', 'md', 'lg']).nullable(),
+        action: z.string(),
+        disabled: z.boolean().nullable(),
+      }),
+      description: 'Clickable button with action',
+    },
+
+    Select: {
+      props: z.object({
+        label: z.string().nullable(),
+        bindPath: z.string(),
+        options: z.array(
+          z.object({
+            value: z.string(),
+            label: z.string(),
+          })
+        ),
+        placeholder: z.string().nullable(),
+      }),
+      description: 'Dropdown select input',
+    },
+
+    DatePicker: {
+      props: z.object({
+        label: z.string().nullable(),
+        bindPath: z.string(),
+        placeholder: z.string().nullable(),
+      }),
+      description: 'Date picker input',
+    },
+
+    // Typography
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(['h1', 'h2', 'h3', 'h4']).nullable(),
+      }),
+      description: 'Section heading',
+    },
+
+    Text: {
+      props: z.object({
+        content: z.string(),
+        variant: z.enum(['body', 'caption', 'label']).nullable(),
+        color: z.enum(['default', 'muted', 'success', 'warning', 'danger']).nullable(),
+      }),
+      description: 'Text paragraph',
+    },
+
+    // Status Components
+    Badge: {
+      props: z.object({
+        text: z.string(),
+        variant: z.enum(['default', 'success', 'warning', 'danger', 'info']).nullable(),
+      }),
+      description: 'Small status badge',
+    },
+
+    Alert: {
+      props: z.object({
+        type: z.enum(['info', 'success', 'warning', 'error']),
+        title: z.string(),
+        message: z.string().nullable(),
+        dismissible: z.boolean().nullable(),
+      }),
+      description: 'Alert/notification banner',
+    },
+
+    // Special Components
+    Divider: {
+      props: z.object({
+        label: z.string().nullable(),
+      }),
+      description: 'Visual divider',
+    },
+
+    Empty: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        action: z.string().nullable(),
+        actionLabel: z.string().nullable(),
+      }),
+      description: 'Empty state placeholder',
+    },
+  },
+  actions: {
+    export_report: { description: 'Export the current dashboard to PDF' },
+    refresh_data: { description: 'Refresh all metrics and charts' },
+    view_details: { description: 'View detailed information' },
+    apply_filter: { description: 'Apply the current filter settings' },
+  },
+  validation: 'strict',
+});
+
+// Export the component list for the AI prompt
+export const componentList = dashboardCatalog.componentNames as string[];

+ 6 - 0
apps/dashboard/next.config.js

@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  transpilePackages: ['@json-render/core', '@json-render/react'],
+};
+
+module.exports = nextConfig;

+ 27 - 0
apps/dashboard/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "dashboard",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack --port 3001",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.13",
+    "ai": "^6.0.33",
+    "jsonui": "workspace:*",
+    "jsonui-react": "workspace:*",
+    "next": "^15.3.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 11 - 0
apps/dashboard/tsconfig.json

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

+ 28 - 0
apps/docs/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "docs",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --port 3002",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "next typegen && tsc --noEmit"
+  },
+  "dependencies": {
+    "@repo/ui": "workspace:*",
+    "next": "16.1.0",
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0"
+  },
+  "devDependencies": {
+    "@repo/eslint-config": "workspace:*",
+    "@repo/typescript-config": "workspace:*",
+    "@types/node": "^22.15.3",
+    "@types/react": "19.2.2",
+    "@types/react-dom": "19.2.2",
+    "eslint": "^9.39.1",
+    "typescript": "5.9.2"
+  }
+}

+ 36 - 0
apps/web/.gitignore

@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# env files (can opt-in for commiting if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts

+ 36 - 0
apps/web/README.md

@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

+ 151 - 0
apps/web/app/docs/actions/page.tsx

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

+ 119 - 0
apps/web/app/docs/ai-sdk/page.tsx

@@ -0,0 +1,119 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "AI SDK Integration | json-render",
+};
+
+export default function AiSdkPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">AI SDK Integration</h1>
+      <p className="text-muted-foreground mb-8">
+        Use json-render with the Vercel AI SDK for seamless streaming.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Installation</h2>
+      <Code lang="bash">npm install ai @ai-sdk/openai</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
+      <Code lang="typescript">{`// app/api/generate/route.ts
+import { streamText } from 'ai';
+import { openai } from '@ai-sdk/openai';
+import { generateCatalogPrompt } from '@json-render/core';
+import { catalog } from '@/lib/catalog';
+
+export async function POST(req: Request) {
+  const { prompt, currentTree } = await req.json();
+  
+  const systemPrompt = generateCatalogPrompt(catalog);
+  
+  // Optionally include current UI state for context
+  const contextPrompt = currentTree 
+    ? \`\\n\\nCurrent UI state:\\n\${JSON.stringify(currentTree, null, 2)}\`
+    : '';
+
+  const result = streamText({
+    model: openai('gpt-4o'),
+    system: systemPrompt + contextPrompt,
+    prompt,
+  });
+
+  return new Response(result.textStream, {
+    headers: { 
+      'Content-Type': 'text/plain; charset=utf-8',
+      'Transfer-Encoding': 'chunked',
+    },
+  });
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Using Gateway</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        For multi-provider support, use the AI Gateway:
+      </p>
+      <Code lang="typescript">{`import { gateway } from '@ai-sdk/gateway';
+
+const result = streamText({
+  model: gateway('openai/gpt-4o'),
+  // ...
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Use <code className="text-foreground">useUIStream</code> on the client:
+      </p>
+      <Code lang="tsx">{`'use client';
+
+import { useUIStream } from '@json-render/react';
+
+function GenerativeUI() {
+  const { tree, isLoading, error, generate } = useUIStream({
+    endpoint: '/api/generate',
+  });
+
+  return (
+    <div>
+      <button 
+        onClick={() => generate('Create a dashboard with metrics')}
+        disabled={isLoading}
+      >
+        {isLoading ? 'Generating...' : 'Generate'}
+      </button>
+      
+      {error && <p className="text-red-500">{error.message}</p>}
+      
+      <Renderer tree={tree} registry={registry} />
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Prompt Engineering</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The <code className="text-foreground">generateCatalogPrompt</code> function creates an optimized prompt that:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Lists all available components and their props</li>
+        <li>Describes available actions</li>
+        <li>Specifies the expected JSON output format</li>
+        <li>Includes examples for better generation</li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Custom System Prompts</h2>
+      <Code lang="typescript">{`const basePrompt = generateCatalogPrompt(catalog);
+
+const customPrompt = \`
+\${basePrompt}
+
+Additional instructions:
+- Always use Card components for grouping related content
+- Prefer horizontal layouts (Row) for metrics
+- Use consistent spacing with padding="md"
+\`;`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about <Link href="/docs/streaming" className="text-foreground hover:underline">progressive streaming</Link>.
+      </p>
+    </article>
+  );
+}

+ 110 - 0
apps/web/app/docs/api/core/page.tsx

@@ -0,0 +1,110 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "@json-render/core API | json-render",
+};
+
+export default function CoreApiPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
+      <p className="text-muted-foreground mb-8">
+        Core types, schemas, and utilities.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">createCatalog</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Creates a catalog definition.
+      </p>
+      <Code lang="typescript">{`function createCatalog(config: CatalogConfig): Catalog
+
+interface CatalogConfig {
+  components: Record<string, ComponentDefinition>;
+  actions?: Record<string, ActionDefinition>;
+  validationFunctions?: Record<string, ValidationFunctionDef>;
+}
+
+interface ComponentDefinition {
+  props: ZodObject;
+  hasChildren?: boolean;
+  description?: string;
+}
+
+interface ActionDefinition {
+  params?: ZodObject;
+  description?: string;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">generateCatalogPrompt</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Generates a system prompt for AI models.
+      </p>
+      <Code lang="typescript">{`function generateCatalogPrompt(catalog: Catalog): string`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Evaluates a visibility condition against data and auth state.
+      </p>
+      <Code lang="typescript">{`function evaluateVisibility(
+  condition: VisibilityCondition | undefined,
+  data: Record<string, unknown>,
+  auth?: AuthState
+): boolean
+
+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] };`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
+      
+      <h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
+      <Code lang="typescript">{`interface UIElement {
+  key: string;
+  type: string;
+  props: Record<string, unknown>;
+  children?: UIElement[];
+  visible?: VisibilityCondition;
+  validation?: ValidationSchema;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">UITree</h3>
+      <Code lang="typescript">{`interface UITree {
+  root: UIElement | null;
+  elements: Record<string, UIElement>;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
+      <Code lang="typescript">{`interface Action {
+  name: string;
+  params?: Record<string, unknown>;
+  confirm?: {
+    title: string;
+    message: string;
+    variant?: 'default' | 'danger';
+  };
+  onSuccess?: { set: Record<string, unknown> };
+  onError?: { set: Record<string, unknown> };
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
+      <Code lang="typescript">{`interface ValidationSchema {
+  checks: ValidationCheck[];
+  validateOn?: 'change' | 'blur' | 'submit';
+}
+
+interface ValidationCheck {
+  fn: string;
+  args?: Record<string, unknown>;
+  message: string;
+}`}</Code>
+    </article>
+  );
+}

+ 110 - 0
apps/web/app/docs/api/react/page.tsx

@@ -0,0 +1,110 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "@json-render/react API | json-render",
+};
+
+export default function ReactApiPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">@json-render/react</h1>
+      <p className="text-muted-foreground mb-8">
+        React components, providers, and hooks.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Providers</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">DataProvider</h3>
+      <Code lang="tsx">{`<DataProvider initialData={object}>
+  {children}
+</DataProvider>`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">ActionProvider</h3>
+      <Code lang="tsx">{`<ActionProvider handlers={Record<string, ActionHandler>}>
+  {children}
+</ActionProvider>
+
+type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">VisibilityProvider</h3>
+      <Code lang="tsx">{`<VisibilityProvider auth={AuthState}>
+  {children}
+</VisibilityProvider>
+
+interface AuthState {
+  isSignedIn: boolean;
+  roles?: string[];
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">ValidationProvider</h3>
+      <Code lang="tsx">{`<ValidationProvider functions={Record<string, ValidatorFn>}>
+  {children}
+</ValidationProvider>
+
+type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
+      <Code lang="tsx">{`<Renderer
+  tree={UITree}
+  registry={ComponentRegistry}
+/>
+
+type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
+
+interface ComponentProps {
+  element: UIElement;
+  children?: React.ReactNode;
+  onAction: (name: string, params: object) => void;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
+      <Code lang="typescript">{`const {
+  tree,       // UITree - current UI state
+  isLoading,  // boolean - true while streaming
+  error,      // Error | null
+  generate,   // (prompt: string) => void
+  abort,      // () => void
+} = useUIStream({
+  endpoint: string,
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
+      <Code lang="typescript">{`const {
+  data,      // Record<string, unknown>
+  setData,   // (data: object) => void
+  getValue,  // (path: string) => unknown
+  setValue,  // (path: string, value: unknown) => void
+} = useData();`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useDataValue</h3>
+      <Code lang="typescript">{`const value = useDataValue(path: string);`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useDataBinding</h3>
+      <Code lang="typescript">{`const [value, setValue] = useDataBinding(path: string);`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useActions</h3>
+      <Code lang="typescript">{`const { dispatch } = useActions();
+// dispatch(actionName: string, params: object)`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useAction</h3>
+      <Code lang="typescript">{`const submitForm = useAction('submit_form');
+// submitForm(params: object)`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useIsVisible</h3>
+      <Code lang="typescript">{`const isVisible = useIsVisible(condition?: VisibilityCondition);`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useFieldValidation</h3>
+      <Code lang="typescript">{`const {
+  value,     // unknown
+  setValue,  // (value: unknown) => void
+  errors,    // string[]
+  validate,  // () => Promise<boolean>
+  isValid,   // boolean
+} = useFieldValidation(path: string, checks: ValidationCheck[]);`}</Code>
+    </article>
+  );
+}

+ 101 - 0
apps/web/app/docs/catalog/page.tsx

@@ -0,0 +1,101 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Catalog | json-render",
+};
+
+export default function CatalogPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Catalog</h1>
+      <p className="text-muted-foreground mb-8">
+        The catalog defines what AI can generate. It&apos;s your guardrail.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        A catalog is a schema that defines:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li><strong className="text-foreground">Components</strong> — UI elements AI can create</li>
+        <li><strong className="text-foreground">Actions</strong> — Operations AI can trigger</li>
+        <li><strong className="text-foreground">Validation Functions</strong> — Custom validators for form inputs</li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+const catalog = createCatalog({
+  components: {
+    // Define each component with its props schema
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        padding: z.enum(['sm', 'md', 'lg']).default('md'),
+      }),
+      hasChildren: true, // Can contain other components
+    },
+    
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(), // JSON Pointer to data
+        format: z.enum(['currency', 'percent', 'number']),
+      }),
+    },
+  },
+  
+  actions: {
+    submit_form: {
+      params: z.object({
+        formId: z.string(),
+      }),
+      description: 'Submit a form',
+    },
+    
+    export_data: {
+      params: z.object({
+        format: z.enum(['csv', 'pdf', 'json']),
+      }),
+    },
+  },
+  
+  validationFunctions: {
+    isValidEmail: {
+      description: 'Validates email format',
+    },
+    isPhoneNumber: {
+      description: 'Validates phone number',
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Each component in the catalog has:
+      </p>
+      <Code lang="typescript">{`{
+  props: z.object({...}),  // Zod schema for props
+  hasChildren?: boolean,    // Can it have children?
+  description?: string,     // Help AI understand when to use it
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Generating AI Prompts</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Use <code className="text-foreground">generateCatalogPrompt</code> to create a system prompt for AI:
+      </p>
+      <Code lang="typescript">{`import { generateCatalogPrompt } from '@json-render/core';
+
+const systemPrompt = generateCatalogPrompt(catalog);
+// Pass this to your AI model as the system prompt`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn how to <Link href="/docs/components" className="text-foreground hover:underline">register React components</Link> for your catalog.
+      </p>
+    </article>
+  );
+}

+ 104 - 0
apps/web/app/docs/components/page.tsx

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

+ 125 - 0
apps/web/app/docs/data-binding/page.tsx

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

+ 53 - 0
apps/web/app/docs/installation/page.tsx

@@ -0,0 +1,53 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Installation | json-render",
+};
+
+export default function InstallationPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Installation</h1>
+      <p className="text-muted-foreground mb-8">
+        Install the core and React packages to get started.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
+      <Code lang="bash">npm install @json-render/core @json-render/react</Code>
+
+      <p className="text-sm text-muted-foreground mb-4">Or with other package managers:</p>
+      <Code lang="bash">{`# pnpm
+pnpm add @json-render/core @json-render/react
+
+# yarn
+yarn add @json-render/core @json-render/react
+
+# bun
+bun add @json-render/core @json-render/react`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        json-render requires the following peer dependencies:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li><code className="text-foreground">react</code> ^18.0.0 or ^19.0.0</li>
+        <li><code className="text-foreground">zod</code> ^3.0.0</li>
+      </ul>
+      <Code lang="bash">npm install react zod</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        To use json-render with AI models, you&apos;ll also need the Vercel AI SDK:
+      </p>
+      <Code lang="bash">npm install ai @ai-sdk/openai</Code>
+
+      <div className="flex gap-3 mt-12">
+        <Button size="sm" asChild>
+          <Link href="/docs/quick-start">Quick Start</Link>
+        </Button>
+      </div>
+    </article>
+  );
+}

+ 77 - 0
apps/web/app/docs/layout.tsx

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

+ 64 - 0
apps/web/app/docs/page.tsx

@@ -0,0 +1,64 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+export const metadata = {
+  title: "Introduction | json-render",
+};
+
+export default function DocsPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Introduction</h1>
+      <p className="text-muted-foreground mb-8">
+        json-render is a library for building AI-powered UIs with enterprise-grade guardrails.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">What is json-render?</h2>
+      <p className="text-sm text-muted-foreground mb-4 leading-relaxed">
+        Unlike vibe-coding tools that let AI generate arbitrary code, json-render gives AI a 
+        constrained vocabulary. You define what components exist, what props they take, and 
+        what actions are available. AI generates JSON that matches your schema, and your 
+        components render it natively.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
+      <div className="space-y-4 mb-8">
+        <div>
+          <h3 className="font-medium mb-1">Consistency</h3>
+          <p className="text-sm text-muted-foreground">
+            AI only uses your approved components. No random UI patterns.
+          </p>
+        </div>
+        <div>
+          <h3 className="font-medium mb-1">Safety</h3>
+          <p className="text-sm text-muted-foreground">
+            Actions are declared by name, you control what they do.
+          </p>
+        </div>
+        <div>
+          <h3 className="font-medium mb-1">Flexibility</h3>
+          <p className="text-sm text-muted-foreground">
+            Define a catalog, let AI generate JSON, render it your way.
+          </p>
+        </div>
+      </div>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">How it works</h2>
+      <ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground mb-8">
+        <li>You define the catalog — what components exist, what props they take</li>
+        <li>AI generates JSON — constrained to your catalog</li>
+        <li>You render it — with your own components</li>
+        <li>Actions are safe — AI declares intent, you implement it</li>
+      </ol>
+
+      <div className="flex gap-3 mt-12">
+        <Button size="sm" asChild>
+          <Link href="/docs/installation">Get Started</Link>
+        </Button>
+        <Button size="sm" variant="outline" asChild>
+          <Link href="/docs/quick-start">Quick Start</Link>
+        </Button>
+      </div>
+    </article>
+  );
+}

+ 172 - 0
apps/web/app/docs/quick-start/page.tsx

@@ -0,0 +1,172 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Quick Start | json-render",
+};
+
+export default function QuickStartPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Quick Start</h1>
+      <p className="text-muted-foreground mb-8">
+        Get up and running with json-render in 5 minutes.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">1. Define your catalog</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a catalog that defines what components AI can use:
+      </p>
+      <Code lang="typescript">{`// lib/catalog.ts
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const catalog = createCatalog({
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      hasChildren: true,
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        action: z.string(),
+      }),
+    },
+    Text: {
+      props: z.object({
+        content: z.string(),
+      }),
+    },
+  },
+  actions: {
+    submit: {
+      params: z.object({ formId: z.string() }),
+    },
+    navigate: {
+      params: z.object({ url: z.string() }),
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">2. Create your components</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Register React components that render each catalog type:
+      </p>
+      <Code lang="tsx">{`// components/registry.tsx
+export const registry = {
+  Card: ({ element, children }) => (
+    <div className="p-4 border rounded-lg">
+      <h2 className="font-bold">{element.props.title}</h2>
+      {element.props.description && (
+        <p className="text-gray-600">{element.props.description}</p>
+      )}
+      {children}
+    </div>
+  ),
+  Button: ({ element, onAction }) => (
+    <button
+      className="px-4 py-2 bg-blue-500 text-white rounded"
+      onClick={() => onAction(element.props.action, {})}
+    >
+      {element.props.label}
+    </button>
+  ),
+  Text: ({ element }) => (
+    <p>{element.props.content}</p>
+  ),
+};`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">3. Create an API route</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Set up a streaming API route for AI generation:
+      </p>
+      <Code lang="typescript">{`// app/api/generate/route.ts
+import { streamText } from 'ai';
+import { openai } from '@ai-sdk/openai';
+import { generateCatalogPrompt } from '@json-render/core';
+import { catalog } from '@/lib/catalog';
+
+export async function POST(req: Request) {
+  const { prompt } = await req.json();
+  const systemPrompt = generateCatalogPrompt(catalog);
+
+  const result = streamText({
+    model: openai('gpt-4o'),
+    system: systemPrompt,
+    prompt,
+  });
+
+  return new Response(result.textStream, {
+    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+  });
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Use the providers and renderer to display AI-generated UI:
+      </p>
+      <Code lang="tsx">{`// app/page.tsx
+'use client';
+
+import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
+import { registry } from '@/components/registry';
+
+export default function Page() {
+  const { tree, isLoading, generate } = useUIStream({
+    endpoint: '/api/generate',
+  });
+
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    const formData = new FormData(e.currentTarget);
+    generate(formData.get('prompt') as string);
+  };
+
+  return (
+    <DataProvider initialData={{}}>
+      <VisibilityProvider>
+        <ActionProvider handlers={{
+          submit: (params) => console.log('Submit:', params),
+          navigate: (params) => console.log('Navigate:', params),
+        }}>
+          <form onSubmit={handleSubmit}>
+            <input
+              name="prompt"
+              placeholder="Describe what you want..."
+              className="border p-2 rounded"
+            />
+            <button type="submit" disabled={isLoading}>
+              Generate
+            </button>
+          </form>
+
+          <div className="mt-8">
+            <Renderer tree={tree} registry={registry} />
+          </div>
+        </ActionProvider>
+      </VisibilityProvider>
+    </DataProvider>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
+        <li>Learn about <Link href="/docs/catalog" className="text-foreground hover:underline">catalogs</Link> in depth</li>
+        <li>Explore <Link href="/docs/data-binding" className="text-foreground hover:underline">data binding</Link> for dynamic values</li>
+        <li>Add <Link href="/docs/actions" className="text-foreground hover:underline">actions</Link> for interactivity</li>
+        <li>Implement <Link href="/docs/visibility" className="text-foreground hover:underline">conditional visibility</Link></li>
+      </ul>
+
+      <div className="flex gap-3 mt-12">
+        <Button size="sm" asChild>
+          <Link href="/docs/catalog">Learn about Catalogs</Link>
+        </Button>
+      </div>
+    </article>
+  );
+}

+ 119 - 0
apps/web/app/docs/streaming/page.tsx

@@ -0,0 +1,119 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Streaming | json-render",
+};
+
+export default function StreamingPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Streaming</h1>
+      <p className="text-muted-foreground mb-8">
+        Progressively render UI as AI generates it.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">How Streaming Works</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        json-render uses JSONL (JSON Lines) streaming. As AI generates, 
+        each line represents a patch operation:
+      </p>
+      <Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
+{"op":"add","path":"/root/children","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
+{"op":"add","path":"/root/children","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The hook handles parsing and state management:
+      </p>
+      <Code lang="tsx">{`import { useUIStream } from '@json-render/react';
+
+function App() {
+  const {
+    tree,        // Current UI tree state
+    isLoading,   // True while streaming
+    error,       // Any error that occurred
+    generate,    // Function to start generation
+    abort,       // Function to cancel streaming
+  } = useUIStream({
+    endpoint: '/api/generate',
+  });
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Supported operations:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
+        <li><code className="text-foreground">set</code> — Set the value at a path (creates if needed)</li>
+        <li><code className="text-foreground">add</code> — Add to an array at a path</li>
+        <li><code className="text-foreground">replace</code> — Replace value at a path</li>
+        <li><code className="text-foreground">remove</code> — Remove value at a path</li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Paths use a key-based format for elements:
+      </p>
+      <Code lang="bash">{`/root              -> Root element
+/root/children     -> Children of root
+/elements/card-1   -> Element with key "card-1"
+/elements/card-1/children -> Children of card-1`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Ensure your API route streams properly:
+      </p>
+      <Code lang="typescript">{`export async function POST(req: Request) {
+  const { prompt } = await req.json();
+  
+  const result = streamText({
+    model: openai('gpt-4o'),
+    system: generateCatalogPrompt(catalog),
+    prompt,
+  });
+
+  // Return as a streaming response
+  return new Response(result.textStream, {
+    headers: {
+      'Content-Type': 'text/plain; charset=utf-8',
+      'Transfer-Encoding': 'chunked',
+      'Cache-Control': 'no-cache',
+    },
+  });
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Progressive Rendering</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The Renderer automatically updates as the tree changes:
+      </p>
+      <Code lang="tsx">{`function App() {
+  const { tree, isLoading } = useUIStream({ endpoint: '/api/generate' });
+
+  return (
+    <div>
+      {isLoading && <LoadingIndicator />}
+      <Renderer tree={tree} registry={registry} />
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
+      <Code lang="tsx">{`function App() {
+  const { isLoading, generate, abort } = useUIStream({
+    endpoint: '/api/generate',
+  });
+
+  return (
+    <div>
+      <button onClick={() => generate('Create dashboard')}>
+        Generate
+      </button>
+      {isLoading && (
+        <button onClick={abort}>Cancel</button>
+      )}
+    </div>
+  );
+}`}</Code>
+    </article>
+  );
+}

+ 147 - 0
apps/web/app/docs/validation/page.tsx

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

+ 136 - 0
apps/web/app/docs/visibility/page.tsx

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

binární
apps/web/app/favicon.ico


binární
apps/web/app/fonts/GeistMonoVF.woff


binární
apps/web/app/fonts/GeistVF.woff


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

@@ -0,0 +1,102 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+  --radius: 0.5rem;
+  /* Monochrome dark theme */
+  --background: oklch(0.0 0 0);
+  --foreground: oklch(0.98 0 0);
+  --card: oklch(0.08 0 0);
+  --card-foreground: oklch(0.98 0 0);
+  --popover: oklch(0.08 0 0);
+  --popover-foreground: oklch(0.98 0 0);
+  --primary: oklch(0.98 0 0);
+  --primary-foreground: oklch(0.0 0 0);
+  --secondary: oklch(0.15 0 0);
+  --secondary-foreground: oklch(0.98 0 0);
+  --muted: oklch(0.15 0 0);
+  --muted-foreground: oklch(0.6 0 0);
+  --accent: oklch(0.15 0 0);
+  --accent-foreground: oklch(0.98 0 0);
+  --destructive: oklch(0.65 0.2 25);
+  --destructive-foreground: oklch(0.98 0 0);
+  --border: oklch(0.2 0 0);
+  --input: oklch(0.2 0 0);
+  --ring: oklch(0.4 0 0);
+}
+
+@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);
+  --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-destructive-foreground: var(--destructive-foreground);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+}
+
+@layer base {
+  * {
+    @apply border-border;
+  }
+  
+  body {
+    @apply bg-background text-foreground antialiased;
+    font-family: var(--font-geist-sans), system-ui, sans-serif;
+  }
+
+  ::selection {
+    @apply bg-foreground text-background;
+  }
+
+  code {
+    font-family: var(--font-geist-mono), ui-monospace, monospace;
+    @apply bg-secondary px-1.5 py-0.5 rounded text-sm;
+  }
+
+  pre {
+    font-family: var(--font-geist-mono), ui-monospace, monospace;
+    @apply bg-card border border-border rounded-lg p-4 overflow-x-auto text-sm leading-relaxed;
+  }
+
+  pre code {
+    @apply bg-transparent p-0;
+  }
+
+  /* Custom scrollbar */
+  ::-webkit-scrollbar {
+    width: 8px;
+    height: 8px;
+  }
+
+  ::-webkit-scrollbar-track {
+    @apply bg-background;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    @apply bg-border rounded;
+  }
+
+  ::-webkit-scrollbar-thumb:hover {
+    @apply bg-muted-foreground;
+  }
+}

+ 38 - 0
apps/web/app/layout.tsx

@@ -0,0 +1,38 @@
+import type { Metadata } from "next";
+import localFont from "next/font/local";
+import "./globals.css";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+
+const geistSans = localFont({
+  src: "./fonts/GeistVF.woff",
+  variable: "--font-geist-sans",
+});
+const geistMono = localFont({
+  src: "./fonts/GeistMonoVF.woff",
+  variable: "--font-geist-mono",
+});
+
+export const metadata: Metadata = {
+  title: "json-render | AI-powered UI from JSON with guardrails",
+  description:
+    "Define a catalog. AI generates JSON. Your components render natively. Enterprise-grade guardrails for controlled AI UI generation.",
+};
+
+export default function RootLayout({
+  children,
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  return (
+    <html lang="en">
+      <body className={`${geistSans.variable} ${geistMono.variable}`}>
+        <div className="min-h-screen flex flex-col">
+          <Header />
+          <main className="flex-1">{children}</main>
+          <Footer />
+        </div>
+      </body>
+    </html>
+  );
+}

+ 172 - 0
apps/web/app/page.tsx

@@ -0,0 +1,172 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Demo } from "@/components/demo";
+
+export default function Home() {
+  return (
+    <>
+      {/* Hero */}
+      <section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
+        <h1 className="text-5xl md:text-7xl font-bold tracking-tighter mb-6">
+          JSON becomes UI
+        </h1>
+        <p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
+          Define a catalog. AI generates JSON. Your components render natively.
+        </p>
+
+        <Demo />
+
+        <div className="flex gap-3 justify-center mt-12">
+          <Button size="lg" asChild>
+            <Link href="/docs">Get Started</Link>
+          </Button>
+          <Button size="lg" variant="outline" asChild>
+            <a
+              href="https://github.com/vercel-labs/json-render"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              GitHub
+            </a>
+          </Button>
+        </div>
+      </section>
+
+      {/* How it works */}
+      <section className="border-t border-border">
+        <div className="max-w-5xl mx-auto px-6 py-24">
+          <div className="grid md:grid-cols-3 gap-12">
+            <div>
+              <div className="text-xs text-muted-foreground font-mono mb-3">01</div>
+              <h3 className="text-lg font-semibold mb-2">Define Catalog</h3>
+              <p className="text-sm text-muted-foreground leading-relaxed">
+                Specify which components AI can use with Zod schemas. Full type safety and validation.
+              </p>
+            </div>
+            <div>
+              <div className="text-xs text-muted-foreground font-mono mb-3">02</div>
+              <h3 className="text-lg font-semibold mb-2">Register Components</h3>
+              <p className="text-sm text-muted-foreground leading-relaxed">
+                Map catalog types to your React components. Use your own design system.
+              </p>
+            </div>
+            <div>
+              <div className="text-xs text-muted-foreground font-mono mb-3">03</div>
+              <h3 className="text-lg font-semibold mb-2">Let AI Generate</h3>
+              <p className="text-sm text-muted-foreground leading-relaxed">
+                AI outputs JSON matching your schema. Stream it. Render progressively.
+              </p>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      {/* Code example */}
+      <section className="border-t border-border">
+        <div className="max-w-5xl mx-auto px-6 py-24">
+          <div className="grid lg:grid-cols-2 gap-12">
+            <div>
+              <h2 className="text-2xl font-semibold mb-4">Define your catalog</h2>
+              <p className="text-muted-foreground mb-6">
+                Components, actions, and validation functions. All type-safe with Zod.
+              </p>
+              <pre className="text-xs">
+                <code>{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const catalog = createCatalog({
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      hasChildren: true,
+    },
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(),
+        format: z.enum(['currency', 'percent']),
+      }),
+    },
+  },
+  actions: {
+    export: { params: z.object({ format: z.string() }) },
+  },
+});`}</code>
+              </pre>
+            </div>
+            <div>
+              <h2 className="text-2xl font-semibold mb-4">AI generates JSON</h2>
+              <p className="text-muted-foreground mb-6">
+                Constrained output that your components render natively.
+              </p>
+              <pre className="text-xs">
+                <code>{`{
+  "key": "dashboard",
+  "type": "Card",
+  "props": {
+    "title": "Revenue Dashboard",
+    "description": null
+  },
+  "children": [
+    {
+      "key": "revenue",
+      "type": "Metric",
+      "props": {
+        "label": "Total Revenue",
+        "valuePath": "/metrics/revenue",
+        "format": "currency"
+      }
+    }
+  ]
+}`}</code>
+              </pre>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      {/* Features */}
+      <section className="border-t border-border">
+        <div className="max-w-5xl mx-auto px-6 py-24">
+          <h2 className="text-2xl font-semibold mb-12 text-center">Features</h2>
+          <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
+            {[
+              { title: "Guardrails", desc: "AI can only use components you define in the catalog" },
+              { title: "Streaming", desc: "Progressive rendering as JSON streams from the model" },
+              { title: "Data Binding", desc: "Two-way binding with JSON Pointer paths" },
+              { title: "Actions", desc: "Named actions handled by your application" },
+              { title: "Visibility", desc: "Conditional show/hide based on data or auth" },
+              { title: "Validation", desc: "Built-in and custom validation functions" },
+            ].map((feature) => (
+              <div key={feature.title}>
+                <h3 className="font-semibold mb-2">{feature.title}</h3>
+                <p className="text-sm text-muted-foreground">{feature.desc}</p>
+              </div>
+            ))}
+          </div>
+        </div>
+      </section>
+
+      {/* CTA */}
+      <section className="border-t border-border">
+        <div className="max-w-4xl mx-auto px-6 py-24 text-center">
+          <h2 className="text-2xl font-semibold mb-4">Get started</h2>
+          <pre className="inline-block text-left mb-8 text-sm">
+            <code>npm install @json-render/core @json-render/react</code>
+          </pre>
+          <div className="flex gap-3 justify-center">
+            <Button asChild>
+              <Link href="/docs">Documentation</Link>
+            </Button>
+            <Button variant="outline" asChild>
+              <Link href="/playground">Playground</Link>
+            </Button>
+          </div>
+        </div>
+      </section>
+    </>
+  );
+}

+ 72 - 0
apps/web/app/playground/page.tsx

@@ -0,0 +1,72 @@
+import { Button } from "@/components/ui/button";
+
+export const metadata = {
+  title: "Playground | json-render",
+};
+
+export default function PlaygroundPage() {
+  return (
+    <div className="max-w-4xl mx-auto px-6 py-16">
+      <h1 className="text-3xl font-bold mb-4">Playground</h1>
+      <p className="text-muted-foreground mb-12">
+        Try json-render with a live example.
+      </p>
+
+      <div className="space-y-12">
+        <section>
+          <h2 className="text-xl font-semibold mb-4">Run locally</h2>
+          <p className="text-sm text-muted-foreground mb-4">
+            Clone the repository and run the example dashboard.
+          </p>
+          <pre className="text-sm mb-4">
+            <code>{`git clone https://github.com/vercel-labs/json-render
+cd json-render
+pnpm install
+pnpm dev`}</code>
+          </pre>
+          <p className="text-sm text-muted-foreground">
+            Open <code>http://localhost:3001</code> for the example dashboard.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="text-xl font-semibold mb-4">Example prompts</h2>
+          <p className="text-sm text-muted-foreground mb-4">
+            Try these prompts in the example dashboard:
+          </p>
+          <div className="space-y-2">
+            {[
+              "Create a revenue dashboard with monthly metrics",
+              "Build a user management panel with a table",
+              "Design a settings form with text inputs",
+              "Make a notification center with alerts",
+            ].map((prompt) => (
+              <div
+                key={prompt}
+                className="p-3 border border-border rounded text-sm font-mono"
+              >
+                {prompt}
+              </div>
+            ))}
+          </div>
+        </section>
+
+        <section>
+          <h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
+          <p className="text-sm text-muted-foreground mb-6">
+            A browser-based playground is coming soon.
+          </p>
+          <Button variant="outline" asChild>
+            <a
+              href="https://github.com/vercel-labs/json-render"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              Star on GitHub
+            </a>
+          </Button>
+        </section>
+      </div>
+    </div>
+  );
+}

+ 22 - 0
apps/web/components.json

@@ -0,0 +1,22 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "app/globals.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "registries": {}
+}

+ 94 - 0
apps/web/components/code-block.tsx

@@ -0,0 +1,94 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { codeToHtml } from "shiki";
+
+const vercelTheme = {
+  name: "vercel",
+  type: "dark" as const,
+  colors: {
+    "editor.background": "transparent",
+    "editor.foreground": "#EDEDED",
+  },
+  settings: [
+    {
+      scope: ["comment", "punctuation.definition.comment"],
+      settings: { foreground: "#666666" },
+    },
+    {
+      scope: ["string", "string.quoted", "string.template"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["keyword", "storage.type", "storage.modifier"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["keyword.operator", "keyword.control"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["entity.name.function", "support.function", "meta.function-call"],
+      settings: { foreground: "#7928CA" },
+    },
+    {
+      scope: ["variable", "variable.other", "variable.parameter"],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["punctuation", "meta.brace", "meta.bracket"],
+      settings: { foreground: "#888888" },
+    },
+    {
+      scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.other.attribute-name"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["support.type.primitive", "entity.name.type.primitive"],
+      settings: { foreground: "#50E3C2" },
+    },
+  ],
+};
+
+interface CodeBlockProps {
+  code: string;
+  lang: "json" | "tsx" | "typescript";
+}
+
+export function CodeBlock({ code, lang }: CodeBlockProps) {
+  const [html, setHtml] = useState<string>("");
+
+  useEffect(() => {
+    codeToHtml(code, {
+      lang,
+      theme: vercelTheme,
+    }).then(setHtml);
+  }, [code, lang]);
+
+  if (!html) {
+    return (
+      <pre className="text-muted-foreground">
+        <code>{code}</code>
+      </pre>
+    );
+  }
+
+  return (
+    <div
+      className="[&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!m-0 [&_code]:!bg-transparent"
+      dangerouslySetInnerHTML={{ __html: html }}
+    />
+  );
+}

+ 79 - 0
apps/web/components/code.tsx

@@ -0,0 +1,79 @@
+import { codeToHtml } from "shiki";
+
+const vercelTheme = {
+  name: "vercel",
+  type: "dark" as const,
+  colors: {
+    "editor.background": "transparent",
+    "editor.foreground": "#EDEDED",
+  },
+  settings: [
+    {
+      scope: ["comment", "punctuation.definition.comment"],
+      settings: { foreground: "#666666" },
+    },
+    {
+      scope: ["string", "string.quoted", "string.template"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["keyword", "storage.type", "storage.modifier"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["keyword.operator", "keyword.control"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["entity.name.function", "support.function", "meta.function-call"],
+      settings: { foreground: "#7928CA" },
+    },
+    {
+      scope: ["variable", "variable.other", "variable.parameter"],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["punctuation", "meta.brace", "meta.bracket"],
+      settings: { foreground: "#888888" },
+    },
+    {
+      scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.other.attribute-name"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["support.type.primitive", "entity.name.type.primitive"],
+      settings: { foreground: "#50E3C2" },
+    },
+  ],
+};
+
+interface CodeProps {
+  children: string;
+  lang?: "json" | "tsx" | "typescript" | "bash" | "javascript";
+}
+
+export async function Code({ children, lang = "typescript" }: CodeProps) {
+  const html = await codeToHtml(children.trim(), {
+    lang,
+    theme: vercelTheme,
+  });
+
+  return (
+    <div
+      className="my-6 rounded-lg border border-border bg-card p-4 overflow-x-auto text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!m-0 [&_code]:!bg-transparent font-mono"
+      dangerouslySetInnerHTML={{ __html: html }}
+    />
+  );
+}

+ 250 - 0
apps/web/components/demo.tsx

@@ -0,0 +1,250 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { CodeBlock } from "./code-block";
+
+const PROMPT = "Create a welcome card with a get started button";
+
+interface StageJson {
+  key: string;
+  type: string;
+  props: {
+    title?: string;
+    description?: string;
+    label?: string;
+    action?: string;
+  };
+  children?: StageJson[];
+}
+
+interface Stage {
+  json: StageJson;
+  stream: string;
+}
+
+const STAGES: Stage[] = [
+  { json: { key: "root", type: "Card", props: {} }, stream: '{"op":"set","path":"/root","value":{"key":"root","type":"Card"}}' },
+  { json: { key: "root", type: "Card", props: { title: "Welcome" } }, stream: '{"op":"replace","path":"/root/props/title","value":"Welcome"}' },
+  { json: { key: "root", type: "Card", props: { title: "Welcome", description: "Get started with json-render" } }, stream: '{"op":"replace","path":"/root/props/description","value":"Get started..."}' },
+  { json: { key: "root", type: "Card", props: { title: "Welcome", description: "Get started with json-render" }, children: [{ key: "btn", type: "Button", props: {} }] }, stream: '{"op":"add","path":"/root/children","value":{"key":"btn","type":"Button"}}' },
+  { json: { key: "root", type: "Card", props: { title: "Welcome", description: "Get started with json-render" }, children: [{ key: "btn", type: "Button", props: { label: "Get Started", action: "start" } }] }, stream: '{"op":"replace","path":"/root/children/0/props","value":{"label":"Get Started"}}' },
+];
+
+const CODE_EXAMPLE = `const registry = {
+  Card: ({ element, children }) => (
+    <div className="card">
+      <h3>{element.props.title}</h3>
+      <p>{element.props.description}</p>
+      {children}
+    </div>
+  ),
+  Button: ({ element, onAction }) => (
+    <button onClick={() => onAction(element.props.action)}>
+      {element.props.label}
+    </button>
+  ),
+};
+
+<Renderer tree={tree} registry={registry} />`;
+
+type Phase = "typing" | "streaming" | "complete";
+type Tab = "stream" | "json" | "code";
+
+export function Demo() {
+  const [phase, setPhase] = useState<Phase>("typing");
+  const [typedPrompt, setTypedPrompt] = useState("");
+  const [stageIndex, setStageIndex] = useState(-1);
+  const [streamLines, setStreamLines] = useState<string[]>([]);
+  const [activeTab, setActiveTab] = useState<Tab>("stream");
+  const [actionFired, setActionFired] = useState(false);
+
+  const currentStage = stageIndex >= 0 ? STAGES[stageIndex] : null;
+
+  const reset = useCallback(() => {
+    setPhase("typing");
+    setTypedPrompt("");
+    setStageIndex(-1);
+    setStreamLines([]);
+    setActiveTab("stream");
+    setActionFired(false);
+  }, []);
+
+  // Typing effect
+  useEffect(() => {
+    if (phase !== "typing") return;
+
+    let i = 0;
+    const interval = setInterval(() => {
+      if (i < PROMPT.length) {
+        setTypedPrompt(PROMPT.slice(0, i + 1));
+        i++;
+      } else {
+        clearInterval(interval);
+        setTimeout(() => setPhase("streaming"), 500);
+      }
+    }, 40);
+
+    return () => clearInterval(interval);
+  }, [phase]);
+
+  // Streaming effect
+  useEffect(() => {
+    if (phase !== "streaming") return;
+
+    let i = 0;
+    const interval = setInterval(() => {
+      if (i < STAGES.length) {
+        const currentIndex = i;
+        const stage = STAGES[currentIndex];
+        if (stage) {
+          setStageIndex(currentIndex);
+          setStreamLines((prev) => [...prev, stage.stream]);
+        }
+        i++;
+      } else {
+        clearInterval(interval);
+        setTimeout(() => {
+          setPhase("complete");
+          setActiveTab("json");
+        }, 500);
+      }
+    }, 600);
+
+    return () => clearInterval(interval);
+  }, [phase]);
+
+  // Auto-restart
+  useEffect(() => {
+    if (phase !== "complete") return;
+
+    const timeout = setTimeout(() => {
+      reset();
+    }, 8000);
+
+    return () => clearTimeout(timeout);
+  }, [phase, reset]);
+
+  const handleAction = () => {
+    setActionFired(true);
+    setTimeout(() => setActionFired(false), 2000);
+  };
+
+  // Progressive render based on current stage
+  const renderPreview = () => {
+    if (!currentStage) {
+      return <div className="text-muted-foreground/50 text-sm">waiting...</div>;
+    }
+
+    const { props, children } = currentStage.json;
+    const hasTitle = props.title;
+    const hasDesc = props.description;
+    const buttonLabel = children?.[0]?.props?.label;
+
+    return (
+      <div className="text-center animate-in fade-in duration-200">
+        <div className="border border-border rounded-lg p-4 bg-background inline-block text-left">
+          {hasTitle ? (
+            <h3 className="font-semibold mb-1">{props.title}</h3>
+          ) : (
+            <div className="h-5 w-20 bg-muted rounded animate-pulse mb-1" />
+          )}
+          {hasDesc ? (
+            <p className="text-xs text-muted-foreground mb-3">{props.description}</p>
+          ) : hasTitle ? (
+            <div className="h-3 w-32 bg-muted rounded animate-pulse mb-3" />
+          ) : null}
+          {buttonLabel ? (
+            <button
+              onClick={handleAction}
+              className="px-3 py-1.5 bg-foreground text-background rounded text-xs font-medium hover:opacity-90 transition-opacity"
+            >
+              {buttonLabel}
+            </button>
+          ) : hasDesc ? (
+            <div className="h-7 w-24 bg-muted rounded animate-pulse" />
+          ) : null}
+        </div>
+        {actionFired && (
+          <div className="mt-3 text-xs font-mono text-muted-foreground animate-in fade-in slide-in-from-bottom-2">
+            onAction(&quot;start&quot;)
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  const jsonCode = currentStage
+    ? JSON.stringify(currentStage.json, null, 2)
+    : "// waiting...";
+
+  return (
+    <div className="w-full max-w-4xl mx-auto">
+      {/* Prompt input */}
+      <div className="mb-6">
+        <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center">
+          <span>{typedPrompt}</span>
+          {phase === "typing" && (
+            <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
+          )}
+        </div>
+      </div>
+
+      <div className="grid lg:grid-cols-2 gap-4">
+        {/* Tabbed code/stream/json panel */}
+        <div>
+          <div className="flex gap-4 mb-2">
+            {(["stream", "json", "code"] as const).map((tab) => (
+              <button
+                key={tab}
+                onClick={() => setActiveTab(tab)}
+                className={`text-xs font-mono transition-colors ${
+                  activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
+                }`}
+              >
+                {tab}
+              </button>
+            ))}
+          </div>
+          <div className="border border-border rounded p-3 bg-card font-mono text-xs h-72 overflow-auto text-left">
+            {activeTab === "stream" && (
+              <div className="space-y-1">
+                {streamLines.map((line, i) => (
+                  <div
+                    key={i}
+                    className="text-muted-foreground truncate animate-in fade-in slide-in-from-bottom-1 duration-200"
+                  >
+                    {line}
+                  </div>
+                ))}
+                {phase === "streaming" && streamLines.length < STAGES.length && (
+                  <div className="flex gap-1 mt-2">
+                    <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
+                    <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
+                    <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
+                  </div>
+                )}
+                {streamLines.length === 0 && phase !== "streaming" && (
+                  <div className="text-muted-foreground/50">waiting...</div>
+                )}
+              </div>
+            )}
+            {activeTab === "json" && (
+              <CodeBlock code={jsonCode} lang="json" />
+            )}
+            {activeTab === "code" && (
+              <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
+            )}
+          </div>
+        </div>
+
+        {/* Rendered output */}
+        <div>
+          <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
+          <div className="border border-border rounded p-3 bg-card h-72 flex items-center justify-center">
+            {renderPreview()}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

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

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

+ 35 - 0
apps/web/components/header.tsx

@@ -0,0 +1,35 @@
+import Link from "next/link";
+
+export function Header() {
+  return (
+    <header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
+      <div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
+        <Link href="/" className="font-semibold hover:opacity-70 transition-opacity">
+          json-render
+        </Link>
+        <nav className="flex gap-6 items-center text-sm">
+          <Link
+            href="/docs"
+            className="text-muted-foreground hover:text-foreground transition-colors"
+          >
+            Docs
+          </Link>
+          <Link
+            href="/playground"
+            className="text-muted-foreground hover:text-foreground transition-colors"
+          >
+            Playground
+          </Link>
+          <a
+            href="https://github.com/vercel-labs/json-render"
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-muted-foreground hover:text-foreground transition-colors"
+          >
+            GitHub
+          </a>
+        </nav>
+      </div>
+    </header>
+  );
+}

+ 46 - 0
apps/web/components/ui/badge.tsx

@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-full border 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:
+          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "border-transparent 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:
+          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant,
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot : "span"
+
+  return (
+    <Comp
+      data-slot="badge"
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+export { Badge, badgeVariants }

+ 62 - 0
apps/web/components/ui/button.tsx

@@ -0,0 +1,62 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean
+  }) {
+  const Comp = asChild ? Slot : "button"
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 92 - 0
apps/web/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,
+}

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

@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      className={cn("flex flex-col gap-2", className)}
+      {...props}
+    />
+  )
+}
+
+function TabsList({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      className={cn(
+        "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground 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-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        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 }

+ 4 - 0
apps/web/eslint.config.js

@@ -0,0 +1,4 @@
+import { nextJsConfig } from "@repo/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default nextJsConfig;

+ 6 - 0
apps/web/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))
+}

+ 4 - 0
apps/web/next.config.js

@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+export default nextConfig;

+ 43 - 0
apps/web/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "web",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack --port 3000",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "next typegen && tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.13",
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@radix-ui/react-slot": "^1.2.4",
+    "@radix-ui/react-tabs": "^1.1.13",
+    "ai": "^6.0.33",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.562.0",
+    "next": "16.1.0",
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0",
+    "shiki": "^3.21.0",
+    "tailwind-merge": "^3.4.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@repo/eslint-config": "workspace:*",
+    "@repo/typescript-config": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "@types/node": "^22.15.3",
+    "@types/react": "19.2.2",
+    "@types/react-dom": "19.2.2",
+    "eslint": "^9.39.1",
+    "postcss": "^8.5.6",
+    "tailwindcss": "^4.1.18",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "5.9.2"
+  }
+}

+ 5 - 0
apps/web/postcss.config.mjs

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

+ 24 - 0
apps/web/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "@repo/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": [
+    "**/*.ts",
+    "**/*.tsx",
+    "next-env.d.ts",
+    "next.config.js",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 80 - 0
examples/dashboard/app/api/generate/route.ts

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

+ 22 - 0
examples/dashboard/app/globals.css

@@ -0,0 +1,22 @@
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+    sans-serif;
+  background-color: #f9fafb;
+}
+
+button {
+  font-family: inherit;
+}
+
+input,
+select,
+textarea {
+  font-family: inherit;
+}

+ 28 - 0
examples/dashboard/app/layout.tsx

@@ -0,0 +1,28 @@
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+  title: 'json-render Dashboard Demo',
+  description: 'AI-powered dashboard widget generator with guardrails',
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body
+        style={{
+          margin: 0,
+          fontFamily:
+            '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+          backgroundColor: '#f9fafb',
+          minHeight: '100vh',
+        }}
+      >
+        {children}
+      </body>
+    </html>
+  );
+}

+ 317 - 0
examples/dashboard/app/page.tsx

@@ -0,0 +1,317 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import {
+  DataProvider,
+  ActionProvider,
+  VisibilityProvider,
+  useUIStream,
+  Renderer,
+} from '@json-render/react';
+import { componentRegistry } from '@/components/ui-components';
+
+// Sample data for the dashboard
+const INITIAL_DATA = {
+  analytics: {
+    revenue: 125000,
+    growth: 0.15,
+    customers: 1234,
+    orders: 567,
+    salesByRegion: [
+      { label: 'US', value: 45000 },
+      { label: 'EU', value: 35000 },
+      { label: 'Asia', value: 28000 },
+      { label: 'Other', value: 17000 },
+    ],
+    recentTransactions: [
+      { id: 'TXN001', customer: 'Acme Corp', amount: 1500, status: 'completed', date: '2024-01-15' },
+      { id: 'TXN002', customer: 'Globex Inc', amount: 2300, status: 'pending', date: '2024-01-14' },
+      { id: 'TXN003', customer: 'Initech', amount: 890, status: 'completed', date: '2024-01-13' },
+      { id: 'TXN004', customer: 'Umbrella Co', amount: 4200, status: 'completed', date: '2024-01-12' },
+    ],
+  },
+  form: {
+    dateRange: '',
+    region: '',
+  },
+};
+
+// Action handlers
+const ACTION_HANDLERS = {
+  export_report: () => {
+    alert('Exporting report... (demo)');
+  },
+  refresh_data: () => {
+    alert('Refreshing data... (demo)');
+  },
+  view_details: (params: Record<string, unknown>) => {
+    alert(`Viewing details for: ${JSON.stringify(params)}`);
+  },
+  apply_filter: () => {
+    alert('Applying filters... (demo)');
+  },
+};
+
+function DashboardContent() {
+  const [prompt, setPrompt] = useState('');
+
+  const {
+    tree,
+    isStreaming,
+    error,
+    send,
+    clear,
+  } = useUIStream({
+    api: '/api/generate',
+    onError: (err) => console.error('Generation error:', err),
+  });
+
+  const handleSubmit = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+      if (!prompt.trim()) return;
+      await send(prompt, { data: INITIAL_DATA });
+    },
+    [prompt, send]
+  );
+
+  const examplePrompts = [
+    'Show me a revenue dashboard with key metrics and a chart of sales by region',
+    'Create a transactions table with recent orders',
+    'Build a widget showing customer count with a trend indicator',
+    'Make a simple card with total revenue and an export button',
+  ];
+
+  const hasElements = tree && Object.keys(tree.elements).length > 0;
+
+  return (
+    <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
+      {/* Header */}
+      <div style={{ marginBottom: '32px' }}>
+        <h1
+          style={{
+            margin: '0 0 8px 0',
+            fontSize: '28px',
+            fontWeight: 700,
+            color: '#111827',
+          }}
+        >
+          json-render Dashboard Demo
+        </h1>
+        <p style={{ margin: 0, color: '#6b7280', fontSize: '16px' }}>
+          AI-powered widget generation with guardrails. Only catalog components can be generated.
+        </p>
+      </div>
+
+      {/* Input Form */}
+      <div
+        style={{
+          backgroundColor: '#fff',
+          borderRadius: '12px',
+          border: '1px solid #e5e7eb',
+          padding: '20px',
+          marginBottom: '24px',
+        }}
+      >
+        <form onSubmit={handleSubmit}>
+          <div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
+            <input
+              type="text"
+              value={prompt}
+              onChange={(e) => setPrompt(e.target.value)}
+              placeholder="Describe the widget you want to create..."
+              style={{
+                flex: 1,
+                padding: '12px 16px',
+                borderRadius: '8px',
+                border: '1px solid #d1d5db',
+                fontSize: '15px',
+                outline: 'none',
+              }}
+              disabled={isStreaming}
+            />
+            <button
+              type="submit"
+              disabled={isStreaming || !prompt.trim()}
+              style={{
+                padding: '12px 24px',
+                backgroundColor: isStreaming ? '#9ca3af' : '#6366f1',
+                color: '#fff',
+                border: 'none',
+                borderRadius: '8px',
+                fontSize: '15px',
+                fontWeight: 500,
+                cursor: isStreaming ? 'not-allowed' : 'pointer',
+              }}
+            >
+              {isStreaming ? 'Generating...' : 'Generate'}
+            </button>
+            <button
+              type="button"
+              onClick={clear}
+              style={{
+                padding: '12px 20px',
+                backgroundColor: '#fff',
+                color: '#374151',
+                border: '1px solid #d1d5db',
+                borderRadius: '8px',
+                fontSize: '15px',
+                fontWeight: 500,
+                cursor: 'pointer',
+              }}
+            >
+              Clear
+            </button>
+          </div>
+
+          {/* Example Prompts */}
+          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
+            <span style={{ fontSize: '13px', color: '#6b7280', marginRight: '4px' }}>
+              Try:
+            </span>
+            {examplePrompts.map((example, i) => (
+              <button
+                key={i}
+                type="button"
+                onClick={() => setPrompt(example)}
+                style={{
+                  padding: '4px 10px',
+                  backgroundColor: '#f3f4f6',
+                  border: 'none',
+                  borderRadius: '4px',
+                  fontSize: '12px',
+                  color: '#4b5563',
+                  cursor: 'pointer',
+                }}
+              >
+                {example.slice(0, 40)}...
+              </button>
+            ))}
+          </div>
+        </form>
+      </div>
+
+      {/* Error Display */}
+      {error && (
+        <div
+          style={{
+            padding: '16px',
+            backgroundColor: '#fef2f2',
+            border: '1px solid #fca5a5',
+            borderRadius: '8px',
+            marginBottom: '24px',
+            color: '#991b1b',
+          }}
+        >
+          <strong>Error:</strong> {error.message}
+        </div>
+      )}
+
+      {/* Generated UI */}
+      <div
+        style={{
+          backgroundColor: '#fff',
+          borderRadius: '12px',
+          border: '1px solid #e5e7eb',
+          padding: '24px',
+          minHeight: '300px',
+        }}
+      >
+        {!hasElements && !isStreaming ? (
+          <div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
+            <div style={{ fontSize: '48px', marginBottom: '16px' }}>🎨</div>
+            <p style={{ fontSize: '16px', margin: 0 }}>
+              Enter a prompt above to generate a dashboard widget
+            </p>
+          </div>
+        ) : tree ? (
+          <Renderer
+            tree={tree}
+            registry={componentRegistry}
+            loading={isStreaming}
+          />
+        ) : null}
+      </div>
+
+      {/* Debug: Show generated JSON */}
+      {hasElements && (
+        <details style={{ marginTop: '24px' }}>
+          <summary
+            style={{
+              cursor: 'pointer',
+              fontSize: '14px',
+              color: '#6b7280',
+              marginBottom: '8px',
+            }}
+          >
+            View Generated JSON
+          </summary>
+          <pre
+            style={{
+              backgroundColor: '#1f2937',
+              color: '#e5e7eb',
+              padding: '16px',
+              borderRadius: '8px',
+              overflow: 'auto',
+              fontSize: '12px',
+            }}
+          >
+            {JSON.stringify(tree, null, 2)}
+          </pre>
+        </details>
+      )}
+
+      {/* Info Box */}
+      <div
+        style={{
+          marginTop: '32px',
+          padding: '20px',
+          backgroundColor: '#eff6ff',
+          borderRadius: '8px',
+          border: '1px solid #bfdbfe',
+        }}
+      >
+        <h3 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1e40af' }}>
+          How json-render Works
+        </h3>
+        <ul
+          style={{
+            margin: 0,
+            paddingLeft: '20px',
+            color: '#1e40af',
+            fontSize: '14px',
+            lineHeight: 1.6,
+          }}
+        >
+          <li>
+            <strong>Guardrails:</strong> AI can only generate components from the catalog (Card,
+            Metric, Chart, etc.)
+          </li>
+          <li>
+            <strong>Data Binding:</strong> Components reference data paths like{' '}
+            <code>/analytics/revenue</code>
+          </li>
+          <li>
+            <strong>Named Actions:</strong> Buttons declare actions like{' '}
+            <code>export_report</code> - you control what they do
+          </li>
+          <li>
+            <strong>Your Design System:</strong> The catalog maps to your actual React components
+          </li>
+        </ul>
+      </div>
+    </div>
+  );
+}
+
+export default function DashboardPage() {
+  return (
+    <DataProvider initialData={INITIAL_DATA}>
+      <VisibilityProvider>
+        <ActionProvider handlers={ACTION_HANDLERS}>
+          <DashboardContent />
+        </ActionProvider>
+      </VisibilityProvider>
+    </DataProvider>
+  );
+}

+ 716 - 0
examples/dashboard/components/ui-components.tsx

@@ -0,0 +1,716 @@
+'use client';
+
+import React, { type ReactNode } from 'react';
+import { type ComponentRenderProps } from '@json-render/react';
+import { useData, useFieldValidation } from '@json-render/react';
+import { getByPath } from '@json-render/core';
+
+// Helper to get value from data model
+function useResolvedValue<T>(value: T | { path: string } | null | undefined): T | undefined {
+  const { data } = useData();
+  
+  if (value === null || value === undefined) {
+    return undefined;
+  }
+  
+  if (typeof value === 'object' && 'path' in value) {
+    return getByPath(data, value.path) as T | undefined;
+  }
+  
+  return value as T;
+}
+
+// Styles
+const styles = {
+  card: {
+    backgroundColor: '#fff',
+    borderRadius: '8px',
+    border: '1px solid #e5e7eb',
+    overflow: 'hidden',
+  },
+  cardHeader: {
+    padding: '16px 20px',
+    borderBottom: '1px solid #e5e7eb',
+  },
+  cardBody: (padding: string | null | undefined) => ({
+    padding:
+      padding === 'none' ? 0 :
+      padding === 'sm' ? '12px' :
+      padding === 'lg' ? '24px' : '16px',
+  }),
+  metric: {
+    display: 'flex',
+    flexDirection: 'column' as const,
+    gap: '4px',
+  },
+  metricLabel: {
+    fontSize: '14px',
+    color: '#6b7280',
+  },
+  metricValue: {
+    fontSize: '32px',
+    fontWeight: 600,
+    color: '#111827',
+  },
+  metricTrend: (trend: string | null | undefined) => ({
+    fontSize: '14px',
+    color: trend === 'up' ? '#10b981' : trend === 'down' ? '#ef4444' : '#6b7280',
+  }),
+};
+
+// Card component
+export function Card({ element, children }: ComponentRenderProps) {
+  const { title, description, padding } = element.props as {
+    title?: string | null;
+    description?: string | null;
+    padding?: string | null;
+  };
+
+  return (
+    <div style={styles.card}>
+      {(title || description) && (
+        <div style={styles.cardHeader}>
+          {title && (
+            <h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>{title}</h3>
+          )}
+          {description && (
+            <p style={{ margin: '4px 0 0', fontSize: '14px', color: '#6b7280' }}>
+              {description}
+            </p>
+          )}
+        </div>
+      )}
+      <div style={styles.cardBody(padding)}>{children}</div>
+    </div>
+  );
+}
+
+// Heading component
+export function Heading({ element }: ComponentRenderProps) {
+  const { text, level } = element.props as {
+    text: string;
+    level?: string | null;
+  };
+
+  const Tag = (level || 'h2') as keyof JSX.IntrinsicElements;
+  const sizes: Record<string, string> = {
+    h1: '28px',
+    h2: '24px',
+    h3: '20px',
+    h4: '16px',
+  };
+
+  return (
+    <Tag style={{ margin: '0 0 16px', fontSize: sizes[level || 'h2'], fontWeight: 600 }}>
+      {text}
+    </Tag>
+  );
+}
+
+// Text component
+export function Text({ element }: ComponentRenderProps) {
+  const { content, variant } = element.props as {
+    content: string;
+    variant?: string | null;
+  };
+
+  const colors: Record<string, string> = {
+    default: '#111827',
+    muted: '#6b7280',
+    success: '#10b981',
+    warning: '#f59e0b',
+    error: '#ef4444',
+  };
+
+  return (
+    <p style={{ margin: 0, color: colors[variant || 'default'] }}>{content}</p>
+  );
+}
+
+// Metric component
+export function Metric({ element }: ComponentRenderProps) {
+  const { label, valuePath, format, trend, trendValue } = element.props as {
+    label: string;
+    valuePath: string;
+    format?: string | null;
+    trend?: string | null;
+    trendValue?: string | null;
+  };
+
+  const { data } = useData();
+  const rawValue = getByPath(data, valuePath);
+
+  let displayValue = String(rawValue ?? '—');
+  if (format === 'currency' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US', {
+      style: 'currency',
+      currency: 'USD',
+    }).format(rawValue);
+  } else if (format === 'percent' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US', {
+      style: 'percent',
+      minimumFractionDigits: 1,
+    }).format(rawValue);
+  } else if (format === 'number' && typeof rawValue === 'number') {
+    displayValue = new Intl.NumberFormat('en-US').format(rawValue);
+  }
+
+  return (
+    <div style={styles.metric}>
+      <span style={styles.metricLabel}>{label}</span>
+      <span style={styles.metricValue}>{displayValue}</span>
+      {(trend || trendValue) && (
+        <span style={styles.metricTrend(trend)}>
+          {trend === 'up' ? '↑' : trend === 'down' ? '↓' : ''} {trendValue}
+        </span>
+      )}
+    </div>
+  );
+}
+
+// Chart component (simplified placeholder)
+export function Chart({ element }: ComponentRenderProps) {
+  const { title, dataPath, type } = element.props as {
+    title?: string | null;
+    dataPath: string;
+    type?: string | null;
+  };
+
+  const { data } = useData();
+  const chartData = getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined;
+
+  if (!chartData || !Array.isArray(chartData)) {
+    return <div style={{ padding: '20px', color: '#6b7280' }}>No chart data</div>;
+  }
+
+  const maxValue = Math.max(...chartData.map((d) => d.value));
+
+  return (
+    <div>
+      {title && (
+        <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
+      )}
+      <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', height: '120px' }}>
+        {chartData.map((d, i) => (
+          <div
+            key={i}
+            style={{
+              flex: 1,
+              display: 'flex',
+              flexDirection: 'column',
+              alignItems: 'center',
+              gap: '4px',
+            }}
+          >
+            <div
+              style={{
+                width: '100%',
+                height: `${(d.value / maxValue) * 100}%`,
+                backgroundColor: '#6366f1',
+                borderRadius: '4px 4px 0 0',
+                minHeight: '4px',
+              }}
+            />
+            <span style={{ fontSize: '12px', color: '#6b7280' }}>{d.label}</span>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// Table component
+export function Table({ element }: ComponentRenderProps) {
+  const { title, dataPath, columns } = element.props as {
+    title?: string | null;
+    dataPath: string;
+    columns: Array<{ key: string; label: string; format?: string | null }>;
+  };
+
+  const { data } = useData();
+  const tableData = getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined;
+
+  if (!tableData || !Array.isArray(tableData)) {
+    return <div style={{ padding: '20px', color: '#6b7280' }}>No data</div>;
+  }
+
+  const formatCell = (value: unknown, format?: string | null) => {
+    if (value === null || value === undefined) return '—';
+    if (format === 'currency' && typeof value === 'number') {
+      return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
+    }
+    if (format === 'date' && typeof value === 'string') {
+      return new Date(value).toLocaleDateString();
+    }
+    if (format === 'badge') {
+      const colors: Record<string, { bg: string; text: string }> = {
+        completed: { bg: '#d1fae5', text: '#065f46' },
+        pending: { bg: '#fef3c7', text: '#92400e' },
+        failed: { bg: '#fee2e2', text: '#991b1b' },
+      };
+      const style = colors[String(value).toLowerCase()] || { bg: '#f3f4f6', text: '#374151' };
+      return (
+        <span
+          style={{
+            padding: '2px 8px',
+            borderRadius: '12px',
+            fontSize: '12px',
+            fontWeight: 500,
+            backgroundColor: style.bg,
+            color: style.text,
+          }}
+        >
+          {String(value)}
+        </span>
+      );
+    }
+    return String(value);
+  };
+
+  return (
+    <div>
+      {title && (
+        <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
+      )}
+      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
+        <thead>
+          <tr>
+            {columns.map((col) => (
+              <th
+                key={col.key}
+                style={{
+                  textAlign: 'left',
+                  padding: '12px 8px',
+                  borderBottom: '1px solid #e5e7eb',
+                  fontSize: '12px',
+                  fontWeight: 600,
+                  color: '#6b7280',
+                  textTransform: 'uppercase',
+                }}
+              >
+                {col.label}
+              </th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {tableData.map((row, i) => (
+            <tr key={i}>
+              {columns.map((col) => (
+                <td
+                  key={col.key}
+                  style={{
+                    padding: '12px 8px',
+                    borderBottom: '1px solid #e5e7eb',
+                    fontSize: '14px',
+                  }}
+                >
+                  {formatCell(row[col.key], col.format)}
+                </td>
+              ))}
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}
+
+// Button component
+export function Button({ element, onAction, loading }: ComponentRenderProps) {
+  const { label, variant, action, disabled } = element.props as {
+    label: string;
+    variant?: string | null;
+    action: { name: string };
+    disabled?: boolean | null;
+  };
+
+  const variants: Record<string, React.CSSProperties> = {
+    primary: { backgroundColor: '#6366f1', color: '#fff', border: 'none' },
+    secondary: { backgroundColor: '#fff', color: '#374151', border: '1px solid #d1d5db' },
+    danger: { backgroundColor: '#dc2626', color: '#fff', border: 'none' },
+    ghost: { backgroundColor: 'transparent', color: '#6b7280', border: 'none' },
+  };
+
+  const handleClick = () => {
+    if (!disabled && action && onAction) {
+      onAction(action);
+    }
+  };
+
+  return (
+    <button
+      onClick={handleClick}
+      disabled={!!disabled || loading}
+      style={{
+        padding: '8px 16px',
+        borderRadius: '6px',
+        fontSize: '14px',
+        fontWeight: 500,
+        cursor: disabled ? 'not-allowed' : 'pointer',
+        opacity: disabled ? 0.5 : 1,
+        ...variants[variant || 'primary'],
+      }}
+    >
+      {loading ? 'Loading...' : label}
+    </button>
+  );
+}
+
+// Alert component
+export function Alert({ element }: ComponentRenderProps) {
+  const { message, variant, dismissible } = element.props as {
+    message: string | { path: string };
+    variant?: string | null;
+    dismissible?: boolean | null;
+  };
+
+  const resolvedMessage = useResolvedValue(message);
+
+  const defaultStyle = { bg: '#eff6ff', border: '#bfdbfe', text: '#1e40af' };
+  const variants: Record<string, { bg: string; border: string; text: string }> = {
+    info: defaultStyle,
+    success: { bg: '#d1fae5', border: '#a7f3d0', text: '#065f46' },
+    warning: { bg: '#fef3c7', border: '#fcd34d', text: '#92400e' },
+    error: { bg: '#fee2e2', border: '#fca5a5', text: '#991b1b' },
+  };
+
+  const style = variants[variant || 'info'] ?? defaultStyle;
+
+  return (
+    <div
+      style={{
+        padding: '12px 16px',
+        borderRadius: '8px',
+        backgroundColor: style.bg,
+        border: `1px solid ${style.border}`,
+        color: style.text,
+        fontSize: '14px',
+      }}
+    >
+      {resolvedMessage}
+    </div>
+  );
+}
+
+// Grid component
+export function Grid({ element, children }: ComponentRenderProps) {
+  const { columns, gap } = element.props as {
+    columns?: number | null;
+    gap?: string | null;
+  };
+
+  const gaps: Record<string, string> = {
+    none: '0',
+    sm: '8px',
+    md: '16px',
+    lg: '24px',
+  };
+
+  return (
+    <div
+      style={{
+        display: 'grid',
+        gridTemplateColumns: `repeat(${columns || 2}, 1fr)`,
+        gap: gaps[gap || 'md'],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+
+// Stack component
+export function Stack({ element, children }: ComponentRenderProps) {
+  const { direction, gap, align } = element.props as {
+    direction?: string | null;
+    gap?: string | null;
+    align?: string | null;
+  };
+
+  const gaps: Record<string, string> = {
+    none: '0',
+    sm: '8px',
+    md: '16px',
+    lg: '24px',
+  };
+
+  const alignments: Record<string, string> = {
+    start: 'flex-start',
+    center: 'center',
+    end: 'flex-end',
+    stretch: 'stretch',
+  };
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        flexDirection: direction === 'horizontal' ? 'row' : 'column',
+        gap: gaps[gap || 'md'],
+        alignItems: alignments[align || 'stretch'],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+
+// Divider component
+export function Divider({ element }: ComponentRenderProps) {
+  const { orientation } = element.props as {
+    orientation?: string | null;
+  };
+
+  if (orientation === 'vertical') {
+    return (
+      <div
+        style={{
+          width: '1px',
+          backgroundColor: '#e5e7eb',
+          alignSelf: 'stretch',
+        }}
+      />
+    );
+  }
+
+  return (
+    <hr
+      style={{
+        border: 'none',
+        borderTop: '1px solid #e5e7eb',
+        margin: '16px 0',
+      }}
+    />
+  );
+}
+
+// Badge component
+export function Badge({ element }: ComponentRenderProps) {
+  const { text, variant } = element.props as {
+    text: string | { path: string };
+    variant?: string | null;
+  };
+
+  const resolvedText = useResolvedValue(text);
+
+  const defaultBadgeStyle = { bg: '#f3f4f6', text: '#374151' };
+  const variants: Record<string, { bg: string; text: string }> = {
+    default: defaultBadgeStyle,
+    success: { bg: '#d1fae5', text: '#065f46' },
+    warning: { bg: '#fef3c7', text: '#92400e' },
+    error: { bg: '#fee2e2', text: '#991b1b' },
+    info: { bg: '#dbeafe', text: '#1e40af' },
+  };
+
+  const style = variants[variant || 'default'] ?? defaultBadgeStyle;
+
+  return (
+    <span
+      style={{
+        display: 'inline-block',
+        padding: '2px 8px',
+        borderRadius: '12px',
+        fontSize: '12px',
+        fontWeight: 500,
+        backgroundColor: style.bg,
+        color: style.text,
+      }}
+    >
+      {resolvedText}
+    </span>
+  );
+}
+
+// TextField component
+export function TextField({ element }: ComponentRenderProps) {
+  const { label, valuePath, placeholder, type, checks, validateOn } = element.props as {
+    label: string;
+    valuePath: string;
+    placeholder?: string | null;
+    type?: string | null;
+    checks?: Array<{ fn: string; message: string }> | null;
+    validateOn?: string | null;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+  
+  const { errors, validate, touch } = useFieldValidation(valuePath, {
+    checks: checks ?? undefined,
+    validateOn: (validateOn as 'change' | 'blur' | 'submit') ?? 'blur',
+  });
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    set(valuePath, e.target.value);
+    if (validateOn === 'change') {
+      validate();
+    }
+  };
+
+  const handleBlur = () => {
+    touch();
+    if (validateOn === 'blur' || !validateOn) {
+      validate();
+    }
+  };
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <input
+        type={type || 'text'}
+        value={value ?? ''}
+        onChange={handleChange}
+        onBlur={handleBlur}
+        placeholder={placeholder ?? ''}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: errors.length > 0 ? '1px solid #ef4444' : '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+        }}
+      />
+      {errors.map((error, i) => (
+        <span key={i} style={{ fontSize: '12px', color: '#ef4444' }}>
+          {error}
+        </span>
+      ))}
+    </div>
+  );
+}
+
+// Select component
+export function Select({ element }: ComponentRenderProps) {
+  const { label, valuePath, options, placeholder } = element.props as {
+    label: string;
+    valuePath: string;
+    options: Array<{ value: string; label: string }>;
+    placeholder?: string | null;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <select
+        value={value ?? ''}
+        onChange={(e) => set(valuePath, e.target.value)}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+          backgroundColor: '#fff',
+        }}
+      >
+        {placeholder && <option value="">{placeholder}</option>}
+        {options.map((opt) => (
+          <option key={opt.value} value={opt.value}>
+            {opt.label}
+          </option>
+        ))}
+      </select>
+    </div>
+  );
+}
+
+// DatePicker component (simplified)
+export function DatePicker({ element }: ComponentRenderProps) {
+  const { label, valuePath } = element.props as {
+    label: string;
+    valuePath: string;
+  };
+
+  const { data, set } = useData();
+  const value = getByPath(data, valuePath) as string | undefined;
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
+      <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
+        {label}
+      </label>
+      <input
+        type="date"
+        value={value ?? ''}
+        onChange={(e) => set(valuePath, e.target.value)}
+        style={{
+          padding: '8px 12px',
+          borderRadius: '6px',
+          border: '1px solid #d1d5db',
+          fontSize: '14px',
+          outline: 'none',
+        }}
+      />
+    </div>
+  );
+}
+
+// Empty component
+export function Empty({ element }: ComponentRenderProps) {
+  const { icon, title, description } = element.props as {
+    icon?: string | null;
+    title: string;
+    description?: string | null;
+  };
+
+  return (
+    <div style={{ textAlign: 'center', padding: '40px 20px' }}>
+      {icon && <div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>}
+      <h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#111827' }}>
+        {title}
+      </h3>
+      {description && (
+        <p style={{ margin: 0, fontSize: '14px', color: '#6b7280' }}>{description}</p>
+      )}
+    </div>
+  );
+}
+
+// List component
+export function List({ element, children }: ComponentRenderProps) {
+  const { dataPath } = element.props as {
+    dataPath: string;
+    itemKey?: string | null;
+  };
+
+  const { data } = useData();
+  const listData = getByPath(data, dataPath) as Array<unknown> | undefined;
+
+  if (!listData || !Array.isArray(listData)) {
+    return <div style={{ color: '#6b7280' }}>No items</div>;
+  }
+
+  // For now, just render children
+  // In a full implementation, children would be cloned for each item
+  return <div>{children}</div>;
+}
+
+// Export all components as a registry
+export const componentRegistry = {
+  Card,
+  Heading,
+  Text,
+  Metric,
+  Chart,
+  Table,
+  Button,
+  Alert,
+  Grid,
+  Stack,
+  Divider,
+  Badge,
+  TextField,
+  Select,
+  DatePicker,
+  Empty,
+  List,
+};

+ 192 - 0
examples/dashboard/lib/catalog.ts

@@ -0,0 +1,192 @@
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+/**
+ * Dashboard component catalog
+ *
+ * This defines the ONLY components that the AI can generate.
+ * It acts as a guardrail - the AI cannot create arbitrary HTML/CSS.
+ * 
+ * Note: OpenAI structured output requires all fields to be required.
+ * Use .nullable() instead of .optional() for optional fields.
+ */
+export const dashboardCatalog = createCatalog({
+  name: 'dashboard',
+  components: {
+    // Layout Components
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+        padding: z.enum(['sm', 'md', 'lg']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'A card container with optional title',
+    },
+
+    Grid: {
+      props: z.object({
+        columns: z.number().min(1).max(4).nullable(),
+        gap: z.enum(['sm', 'md', 'lg']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'Grid layout with configurable columns',
+    },
+
+    Stack: {
+      props: z.object({
+        direction: z.enum(['horizontal', 'vertical']).nullable(),
+        gap: z.enum(['sm', 'md', 'lg']).nullable(),
+        align: z.enum(['start', 'center', 'end', 'stretch']).nullable(),
+      }),
+      hasChildren: true,
+      description: 'Flex stack for horizontal or vertical layouts',
+    },
+
+    // Data Display Components
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(),
+        format: z.enum(['number', 'currency', 'percent']).nullable(),
+        trend: z.enum(['up', 'down', 'neutral']).nullable(),
+        trendValue: z.string().nullable(),
+      }),
+      description: 'Display a single metric with optional trend indicator',
+    },
+
+    Chart: {
+      props: z.object({
+        type: z.enum(['bar', 'line', 'pie', 'area']),
+        dataPath: z.string(),
+        title: z.string().nullable(),
+        height: z.number().nullable(),
+      }),
+      description: 'Display a chart from array data',
+    },
+
+    Table: {
+      props: z.object({
+        dataPath: z.string(),
+        columns: z.array(
+          z.object({
+            key: z.string(),
+            label: z.string(),
+            format: z.enum(['text', 'currency', 'date', 'badge']).nullable(),
+          })
+        ),
+      }),
+      description: 'Display tabular data',
+    },
+
+    List: {
+      props: z.object({
+        dataPath: z.string(),
+        emptyMessage: z.string().nullable(),
+      }),
+      hasChildren: true,
+      description: 'Render a list from array data',
+    },
+
+    // Interactive Components
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(['primary', 'secondary', 'danger', 'ghost']).nullable(),
+        size: z.enum(['sm', 'md', 'lg']).nullable(),
+        action: z.string(),
+        disabled: z.boolean().nullable(),
+      }),
+      description: 'Clickable button with action',
+    },
+
+    Select: {
+      props: z.object({
+        label: z.string().nullable(),
+        bindPath: z.string(),
+        options: z.array(
+          z.object({
+            value: z.string(),
+            label: z.string(),
+          })
+        ),
+        placeholder: z.string().nullable(),
+      }),
+      description: 'Dropdown select input',
+    },
+
+    DatePicker: {
+      props: z.object({
+        label: z.string().nullable(),
+        bindPath: z.string(),
+        placeholder: z.string().nullable(),
+      }),
+      description: 'Date picker input',
+    },
+
+    // Typography
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(['h1', 'h2', 'h3', 'h4']).nullable(),
+      }),
+      description: 'Section heading',
+    },
+
+    Text: {
+      props: z.object({
+        content: z.string(),
+        variant: z.enum(['body', 'caption', 'label']).nullable(),
+        color: z.enum(['default', 'muted', 'success', 'warning', 'danger']).nullable(),
+      }),
+      description: 'Text paragraph',
+    },
+
+    // Status Components
+    Badge: {
+      props: z.object({
+        text: z.string(),
+        variant: z.enum(['default', 'success', 'warning', 'danger', 'info']).nullable(),
+      }),
+      description: 'Small status badge',
+    },
+
+    Alert: {
+      props: z.object({
+        type: z.enum(['info', 'success', 'warning', 'error']),
+        title: z.string(),
+        message: z.string().nullable(),
+        dismissible: z.boolean().nullable(),
+      }),
+      description: 'Alert/notification banner',
+    },
+
+    // Special Components
+    Divider: {
+      props: z.object({
+        label: z.string().nullable(),
+      }),
+      description: 'Visual divider',
+    },
+
+    Empty: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        action: z.string().nullable(),
+        actionLabel: z.string().nullable(),
+      }),
+      description: 'Empty state placeholder',
+    },
+  },
+  actions: {
+    export_report: { description: 'Export the current dashboard to PDF' },
+    refresh_data: { description: 'Refresh all metrics and charts' },
+    view_details: { description: 'View detailed information' },
+    apply_filter: { description: 'Apply the current filter settings' },
+  },
+  validation: 'strict',
+});
+
+// Export the component list for the AI prompt
+export const componentList = dashboardCatalog.componentNames as string[];

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

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

+ 6 - 0
examples/dashboard/next.config.js

@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  transpilePackages: ['@json-render/core', '@json-render/react'],
+};
+
+module.exports = nextConfig;

+ 27 - 0
examples/dashboard/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "dashboard",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack --port 3001",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.13",
+    "ai": "^6.0.33",
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "next": "^15.3.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 11 - 0
examples/dashboard/tsconfig.json

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

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "json-render",
+  "private": true,
+  "scripts": {
+    "build": "turbo run build",
+    "dev": "turbo run dev --concurrency 15",
+    "lint": "turbo run lint",
+    "format": "prettier --write \"**/*.{ts,tsx,md}\"",
+    "check-types": "turbo run check-types"
+  },
+  "devDependencies": {
+    "prettier": "^3.7.4",
+    "turbo": "^2.7.4",
+    "typescript": "5.9.2"
+  },
+  "packageManager": "pnpm@9.0.0",
+  "engines": {
+    "node": ">=18"
+  }
+}

+ 184 - 0
packages/core/README.md

@@ -0,0 +1,184 @@
+# @json-render/core
+
+Enterprise-grade JSON-driven UI library with conditional visibility, rich actions, and enhanced validation.
+
+## Features
+
+- **Conditional Visibility**: Show/hide components based on data paths, auth state, or complex logic expressions
+- **Rich Actions**: Actions with typed parameters, confirmation dialogs, and success/error callbacks
+- **Enhanced Validation**: Built-in validation functions with custom catalog functions support
+- **Type-Safe Catalog**: Define component schemas using Zod for full type safety
+- **Framework Agnostic**: Core logic is independent of UI frameworks
+
+## Installation
+
+```bash
+npm install @json-render/core
+# or
+pnpm add @json-render/core
+```
+
+## Quick Start
+
+### Create a Catalog
+
+```typescript
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+const catalog = createCatalog({
+  name: 'My Dashboard',
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      hasChildren: true,
+      description: 'A card container',
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        action: ActionSchema,
+      }),
+      description: 'A clickable button',
+    },
+  },
+  actions: {
+    submit: { description: 'Submit the form' },
+    export: { 
+      params: z.object({ format: z.enum(['csv', 'pdf']) }),
+      description: 'Export data',
+    },
+  },
+  functions: {
+    customValidation: (value) => typeof value === 'string' && value.length > 0,
+  },
+});
+```
+
+### Visibility Conditions
+
+```typescript
+import { visibility, evaluateVisibility } from '@json-render/core';
+
+// Simple path-based visibility
+const element1 = {
+  key: 'error-banner',
+  type: 'Alert',
+  props: { message: 'Error!' },
+  visible: { path: '/form/hasError' },
+};
+
+// Auth-based visibility
+const element2 = {
+  key: 'admin-panel',
+  type: 'Card',
+  props: { title: 'Admin' },
+  visible: { auth: 'signedIn' },
+};
+
+// Complex logic
+const element3 = {
+  key: 'notification',
+  type: 'Alert',
+  props: { message: 'Warning' },
+  visible: {
+    and: [
+      { path: '/settings/notifications' },
+      { not: { path: '/user/dismissed' } },
+      { gt: [{ path: '/items/count' }, 10] },
+    ],
+  },
+};
+
+// Evaluate visibility
+const isVisible = evaluateVisibility(element1.visible, {
+  dataModel: { form: { hasError: true } },
+});
+```
+
+### Rich Actions
+
+```typescript
+import { resolveAction, executeAction } from '@json-render/core';
+
+const buttonAction = {
+  name: 'refund',
+  params: {
+    paymentId: { path: '/selected/id' },
+    amount: 100,
+  },
+  confirm: {
+    title: 'Confirm Refund',
+    message: 'Refund $100 to customer?',
+    variant: 'danger',
+  },
+  onSuccess: { navigate: '/payments' },
+  onError: { set: { '/ui/error': '$error.message' } },
+};
+
+// Resolve dynamic values
+const resolved = resolveAction(buttonAction, dataModel);
+```
+
+### Validation
+
+```typescript
+import { runValidation, check } from '@json-render/core';
+
+const config = {
+  checks: [
+    check.required('Email is required'),
+    check.email('Invalid email'),
+    check.maxLength(100, 'Too long'),
+  ],
+  validateOn: 'blur',
+};
+
+const result = runValidation(config, {
+  value: 'user@example.com',
+  dataModel: {},
+});
+
+// result.valid = true
+// result.errors = []
+```
+
+## API Reference
+
+### Visibility
+
+- `evaluateVisibility(condition, context)` - Evaluate a visibility condition
+- `evaluateLogicExpression(expr, context)` - Evaluate a logic expression
+- `visibility.*` - Helper functions for creating visibility conditions
+
+### Actions
+
+- `resolveAction(action, dataModel)` - Resolve dynamic values in an action
+- `executeAction(context)` - Execute an action with callbacks
+- `interpolateString(template, dataModel)` - Interpolate `${path}` in strings
+
+### Validation
+
+- `runValidation(config, context)` - Run validation checks
+- `runValidationCheck(check, context)` - Run a single validation check
+- `builtInValidationFunctions` - Built-in validators (required, email, min, max, etc.)
+- `check.*` - Helper functions for creating validation checks
+
+### Catalog
+
+- `createCatalog(config)` - Create a catalog with components, actions, and functions
+- `generateCatalogPrompt(catalog)` - Generate an AI prompt describing the catalog
+
+## Types
+
+See `src/types.ts` for full type definitions:
+
+- `UIElement` - Base element structure
+- `UITree` - Flat tree structure
+- `VisibilityCondition` - Visibility condition types
+- `LogicExpression` - Logic expression types
+- `Action` - Rich action definition
+- `ValidationConfig` - Validation configuration

+ 34 - 0
packages/core/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@json-render/core",
+  "version": "0.1.0",
+  "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "zod": "^3.23.8"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "zod": "^3.0.0"
+  }
+}

+ 243 - 0
packages/core/src/actions.ts

@@ -0,0 +1,243 @@
+import { z } from 'zod';
+import type { DynamicValue, DataModel } from './types';
+import { DynamicValueSchema, resolveDynamicValue } from './types';
+
+/**
+ * Confirmation dialog configuration
+ */
+export interface ActionConfirm {
+  title: string;
+  message: string;
+  confirmLabel?: string;
+  cancelLabel?: string;
+  variant?: 'default' | 'danger';
+}
+
+/**
+ * Action success handler
+ */
+export type ActionOnSuccess =
+  | { navigate: string }
+  | { set: Record<string, unknown> }
+  | { action: string };
+
+/**
+ * Action error handler
+ */
+export type ActionOnError =
+  | { set: Record<string, unknown> }
+  | { action: string };
+
+/**
+ * Rich action definition
+ */
+export interface Action {
+  /** Action name (must be in catalog) */
+  name: string;
+  /** Parameters to pass to the action handler */
+  params?: Record<string, DynamicValue>;
+  /** Confirmation dialog before execution */
+  confirm?: ActionConfirm;
+  /** Handler after successful execution */
+  onSuccess?: ActionOnSuccess;
+  /** Handler after failed execution */
+  onError?: ActionOnError;
+}
+
+/**
+ * Schema for action confirmation
+ */
+export const ActionConfirmSchema = z.object({
+  title: z.string(),
+  message: z.string(),
+  confirmLabel: z.string().optional(),
+  cancelLabel: z.string().optional(),
+  variant: z.enum(['default', 'danger']).optional(),
+});
+
+/**
+ * Schema for success handlers
+ */
+export const ActionOnSuccessSchema = z.union([
+  z.object({ navigate: z.string() }),
+  z.object({ set: z.record(z.unknown()) }),
+  z.object({ action: z.string() }),
+]);
+
+/**
+ * Schema for error handlers
+ */
+export const ActionOnErrorSchema = z.union([
+  z.object({ set: z.record(z.unknown()) }),
+  z.object({ action: z.string() }),
+]);
+
+/**
+ * Full action schema
+ */
+export const ActionSchema = z.object({
+  name: z.string(),
+  params: z.record(DynamicValueSchema).optional(),
+  confirm: ActionConfirmSchema.optional(),
+  onSuccess: ActionOnSuccessSchema.optional(),
+  onError: ActionOnErrorSchema.optional(),
+});
+
+/**
+ * Action handler function signature
+ */
+export type ActionHandler<TParams = Record<string, unknown>, TResult = unknown> = (
+  params: TParams
+) => Promise<TResult> | TResult;
+
+/**
+ * Action definition in catalog
+ */
+export interface ActionDefinition<TParams = Record<string, unknown>> {
+  /** Zod schema for params validation */
+  params?: z.ZodType<TParams>;
+  /** Description for AI */
+  description?: string;
+}
+
+/**
+ * Resolved action with all dynamic values resolved
+ */
+export interface ResolvedAction {
+  name: string;
+  params: Record<string, unknown>;
+  confirm?: ActionConfirm;
+  onSuccess?: ActionOnSuccess;
+  onError?: ActionOnError;
+}
+
+/**
+ * Resolve all dynamic values in an action
+ */
+export function resolveAction(action: Action, dataModel: DataModel): ResolvedAction {
+  const resolvedParams: Record<string, unknown> = {};
+  
+  if (action.params) {
+    for (const [key, value] of Object.entries(action.params)) {
+      resolvedParams[key] = resolveDynamicValue(value, dataModel);
+    }
+  }
+  
+  // Interpolate confirmation message if present
+  let confirm = action.confirm;
+  if (confirm) {
+    confirm = {
+      ...confirm,
+      message: interpolateString(confirm.message, dataModel),
+      title: interpolateString(confirm.title, dataModel),
+    };
+  }
+  
+  return {
+    name: action.name,
+    params: resolvedParams,
+    confirm,
+    onSuccess: action.onSuccess,
+    onError: action.onError,
+  };
+}
+
+/**
+ * Interpolate ${path} expressions in a string
+ */
+export function interpolateString(template: string, dataModel: DataModel): string {
+  return template.replace(/\$\{([^}]+)\}/g, (_, path) => {
+    const value = resolveDynamicValue({ path }, dataModel);
+    return String(value ?? '');
+  });
+}
+
+/**
+ * Context for action execution
+ */
+export interface ActionExecutionContext {
+  /** The resolved action */
+  action: ResolvedAction;
+  /** The action handler from the host */
+  handler: ActionHandler;
+  /** Function to update data model */
+  setData: (path: string, value: unknown) => void;
+  /** Function to navigate */
+  navigate?: (path: string) => void;
+  /** Function to execute another action */
+  executeAction?: (name: string) => Promise<void>;
+}
+
+/**
+ * Execute an action with all callbacks
+ */
+export async function executeAction(ctx: ActionExecutionContext): Promise<void> {
+  const { action, handler, setData, navigate, executeAction } = ctx;
+  
+  try {
+    await handler(action.params);
+    
+    // Handle success
+    if (action.onSuccess) {
+      if ('navigate' in action.onSuccess && navigate) {
+        navigate(action.onSuccess.navigate);
+      } else if ('set' in action.onSuccess) {
+        for (const [path, value] of Object.entries(action.onSuccess.set)) {
+          setData(path, value);
+        }
+      } else if ('action' in action.onSuccess && executeAction) {
+        await executeAction(action.onSuccess.action);
+      }
+    }
+  } catch (error) {
+    // Handle error
+    if (action.onError) {
+      if ('set' in action.onError) {
+        for (const [path, value] of Object.entries(action.onError.set)) {
+          // Replace $error.message with actual error
+          const resolvedValue = typeof value === 'string' && value === '$error.message'
+            ? (error as Error).message
+            : value;
+          setData(path, resolvedValue);
+        }
+      } else if ('action' in action.onError && executeAction) {
+        await executeAction(action.onError.action);
+      }
+    } else {
+      throw error;
+    }
+  }
+}
+
+/**
+ * Helper to create actions
+ */
+export const action = {
+  /** Create a simple action */
+  simple: (name: string, params?: Record<string, DynamicValue>): Action => ({
+    name,
+    params,
+  }),
+  
+  /** Create an action with confirmation */
+  withConfirm: (
+    name: string,
+    confirm: ActionConfirm,
+    params?: Record<string, DynamicValue>
+  ): Action => ({
+    name,
+    params,
+    confirm,
+  }),
+  
+  /** Create an action with success handler */
+  withSuccess: (
+    name: string,
+    onSuccess: ActionOnSuccess,
+    params?: Record<string, DynamicValue>
+  ): Action => ({
+    name,
+    params,
+    onSuccess,
+  }),
+};

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

@@ -0,0 +1,259 @@
+import { z } from 'zod';
+import type {
+  ComponentSchema,
+  ValidationMode,
+  UIElement,
+  UITree,
+  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;
+}
+
+/**
+ * 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 tree schema */
+  readonly treeSchema: z.ZodType<UITree>;
+  /** 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 tree */
+  validateTree(tree: unknown): { success: boolean; data?: UITree; 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({
+      key: z.string(),
+      type: z.literal(componentName as string),
+      props: def.props,
+      children: z.array(z.string()).optional(),
+      parentKey: z.string().nullable().optional(),
+      visible: VisibilityConditionSchema.optional(),
+    });
+  });
+
+  // Create union schema for all components
+  let elementSchema: z.ZodType<UIElement>;
+  
+  if (componentSchemas.length === 0) {
+    elementSchema = z.object({
+      key: z.string(),
+      type: z.string(),
+      props: z.record(z.unknown()),
+      children: z.array(z.string()).optional(),
+      parentKey: z.string().nullable().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 tree schema
+  const treeSchema = z.object({
+    root: z.string(),
+    elements: z.record(elementSchema),
+  }) as unknown as z.ZodType<UITree>;
+
+  return {
+    name,
+    componentNames,
+    actionNames,
+    functionNames,
+    validation,
+    components,
+    actions,
+    functions,
+    elementSchema,
+    treeSchema,
+
+    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 };
+    },
+
+    validateTree(tree: unknown) {
+      const result = treeSchema.safeParse(tree);
+      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": "/data/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']>;
+};

+ 96 - 0
packages/core/src/index.ts

@@ -0,0 +1,96 @@
+// Types
+export type {
+  DynamicValue,
+  DynamicString,
+  DynamicNumber,
+  DynamicBoolean,
+  UIElement,
+  UITree,
+  VisibilityCondition,
+  LogicExpression,
+  AuthState,
+  DataModel,
+  ComponentSchema,
+  ValidationMode,
+  PatchOp,
+  JsonPatch,
+} from './types';
+
+export {
+  DynamicValueSchema,
+  DynamicStringSchema,
+  DynamicNumberSchema,
+  DynamicBooleanSchema,
+  resolveDynamicValue,
+  getByPath,
+  setByPath,
+} from './types';
+
+// Visibility
+export type {
+  VisibilityContext,
+} from './visibility';
+
+export {
+  VisibilityConditionSchema,
+  LogicExpressionSchema,
+  evaluateVisibility,
+  evaluateLogicExpression,
+  visibility,
+} from './visibility';
+
+// Actions
+export type {
+  Action,
+  ActionConfirm,
+  ActionOnSuccess,
+  ActionOnError,
+  ActionHandler,
+  ActionDefinition,
+  ResolvedAction,
+  ActionExecutionContext,
+} from './actions';
+
+export {
+  ActionSchema,
+  ActionConfirmSchema,
+  ActionOnSuccessSchema,
+  ActionOnErrorSchema,
+  resolveAction,
+  executeAction,
+  interpolateString,
+  action,
+} from './actions';
+
+// Validation
+export type {
+  ValidationCheck,
+  ValidationConfig,
+  ValidationFunction,
+  ValidationFunctionDefinition,
+  ValidationCheckResult,
+  ValidationResult,
+  ValidationContext,
+} from './validation';
+
+export {
+  ValidationCheckSchema,
+  ValidationConfigSchema,
+  builtInValidationFunctions,
+  runValidationCheck,
+  runValidation,
+  check,
+} from './validation';
+
+// Catalog
+export type {
+  ComponentDefinition,
+  CatalogConfig,
+  Catalog,
+  InferCatalogComponentProps,
+} from './catalog';
+
+export {
+  createCatalog,
+  generateCatalogPrompt,
+} from './catalog';

+ 209 - 0
packages/core/src/types.ts

@@ -0,0 +1,209 @@
+import { z } from 'zod';
+
+/**
+ * Dynamic value - can be a literal or a path reference to data model
+ */
+export type DynamicValue<T = unknown> =
+  | T
+  | { path: string };
+
+/**
+ * Dynamic string value
+ */
+export type DynamicString = DynamicValue<string>;
+
+/**
+ * Dynamic number value
+ */
+export type DynamicNumber = DynamicValue<number>;
+
+/**
+ * Dynamic boolean value
+ */
+export type DynamicBoolean = DynamicValue<boolean>;
+
+/**
+ * Zod schema for dynamic values
+ */
+export const DynamicValueSchema = z.union([
+  z.string(),
+  z.number(),
+  z.boolean(),
+  z.null(),
+  z.object({ path: z.string() }),
+]);
+
+export const DynamicStringSchema = z.union([
+  z.string(),
+  z.object({ path: z.string() }),
+]);
+
+export const DynamicNumberSchema = z.union([
+  z.number(),
+  z.object({ path: z.string() }),
+]);
+
+export const DynamicBooleanSchema = z.union([
+  z.boolean(),
+  z.object({ path: z.string() }),
+]);
+
+/**
+ * Base UI element structure for v2
+ */
+export interface UIElement<T extends string = string, P = Record<string, unknown>> {
+  /** Unique key for reconciliation */
+  key: string;
+  /** Component type from the catalog */
+  type: T;
+  /** Component props */
+  props: P;
+  /** Child element keys (flat structure) */
+  children?: string[];
+  /** Parent element key (null for root) */
+  parentKey?: string | null;
+  /** Visibility condition */
+  visible?: VisibilityCondition;
+}
+
+/**
+ * Visibility condition types
+ */
+export type VisibilityCondition =
+  | boolean
+  | { path: string }
+  | { auth: 'signedIn' | 'signedOut' }
+  | LogicExpression;
+
+/**
+ * Logic expression for complex conditions
+ */
+export type LogicExpression =
+  | { and: LogicExpression[] }
+  | { or: LogicExpression[] }
+  | { not: LogicExpression }
+  | { path: string }
+  | { eq: [DynamicValue, DynamicValue] }
+  | { neq: [DynamicValue, DynamicValue] }
+  | { gt: [DynamicValue<number>, DynamicValue<number>] }
+  | { gte: [DynamicValue<number>, DynamicValue<number>] }
+  | { lt: [DynamicValue<number>, DynamicValue<number>] }
+  | { lte: [DynamicValue<number>, DynamicValue<number>] };
+
+/**
+ * Flat UI tree structure (optimized for LLM generation)
+ */
+export interface UITree {
+  /** Root element key */
+  root: string;
+  /** Flat map of elements by key */
+  elements: Record<string, UIElement>;
+}
+
+/**
+ * Auth state for visibility evaluation
+ */
+export interface AuthState {
+  isSignedIn: boolean;
+  user?: Record<string, unknown>;
+}
+
+/**
+ * Data model type
+ */
+export type DataModel = Record<string, unknown>;
+
+/**
+ * Component schema definition using Zod
+ */
+export type ComponentSchema = z.ZodType<Record<string, unknown>>;
+
+/**
+ * Validation mode for catalog validation
+ */
+export type ValidationMode = 'strict' | 'warn' | 'ignore';
+
+/**
+ * JSON patch operation types
+ */
+export type PatchOp = 'add' | 'remove' | 'replace' | 'set';
+
+/**
+ * JSON patch operation
+ */
+export interface JsonPatch {
+  op: PatchOp;
+  path: string;
+  value?: unknown;
+}
+
+/**
+ * Resolve a dynamic value against a data model
+ */
+export function resolveDynamicValue<T>(
+  value: DynamicValue<T>,
+  dataModel: DataModel
+): T | undefined {
+  if (value === null || value === undefined) {
+    return undefined;
+  }
+  
+  if (typeof value === 'object' && 'path' in value) {
+    return getByPath(dataModel, value.path) as T | undefined;
+  }
+  
+  return value as T;
+}
+
+/**
+ * Get a value from an object by JSON Pointer path
+ */
+export function getByPath(obj: unknown, path: string): unknown {
+  if (!path || path === '/') {
+    return obj;
+  }
+  
+  const segments = path.startsWith('/')
+    ? path.slice(1).split('/')
+    : path.split('/');
+  
+  let current: unknown = obj;
+  
+  for (const segment of segments) {
+    if (current === null || current === undefined) {
+      return undefined;
+    }
+    
+    if (typeof current === 'object') {
+      current = (current as Record<string, unknown>)[segment];
+    } else {
+      return undefined;
+    }
+  }
+  
+  return current;
+}
+
+/**
+ * Set a value in an object by JSON Pointer path
+ */
+export function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
+  const segments = path.startsWith('/')
+    ? path.slice(1).split('/')
+    : path.split('/');
+  
+  if (segments.length === 0) return;
+  
+  let current: Record<string, unknown> = obj;
+  
+  for (let i = 0; i < segments.length - 1; i++) {
+    const segment = segments[i]!;
+    if (!(segment in current) || typeof current[segment] !== 'object') {
+      current[segment] = {};
+    }
+    current = current[segment] as Record<string, unknown>;
+  }
+  
+  const lastSegment = segments[segments.length - 1]!;
+  current[lastSegment] = value;
+}

+ 334 - 0
packages/core/src/validation.ts

@@ -0,0 +1,334 @@
+import { z } from 'zod';
+import type { DynamicValue, DataModel, LogicExpression } from './types';
+import { DynamicValueSchema, resolveDynamicValue } from './types';
+import { LogicExpressionSchema, evaluateLogicExpression } from './visibility';
+
+/**
+ * Validation check definition
+ */
+export interface ValidationCheck {
+  /** Function name (built-in or from catalog) */
+  fn: string;
+  /** Additional arguments for the function */
+  args?: Record<string, DynamicValue>;
+  /** Error message to display if check fails */
+  message: string;
+}
+
+/**
+ * Validation configuration for a field
+ */
+export interface ValidationConfig {
+  /** Array of checks to run */
+  checks?: ValidationCheck[];
+  /** When to run validation */
+  validateOn?: 'change' | 'blur' | 'submit';
+  /** Condition for when validation is enabled */
+  enabled?: LogicExpression;
+}
+
+/**
+ * Schema for validation check
+ */
+export const ValidationCheckSchema = z.object({
+  fn: z.string(),
+  args: z.record(DynamicValueSchema).optional(),
+  message: z.string(),
+});
+
+/**
+ * Schema for validation config
+ */
+export const ValidationConfigSchema = z.object({
+  checks: z.array(ValidationCheckSchema).optional(),
+  validateOn: z.enum(['change', 'blur', 'submit']).optional(),
+  enabled: LogicExpressionSchema.optional(),
+});
+
+/**
+ * Validation function signature
+ */
+export type ValidationFunction = (
+  value: unknown,
+  args?: Record<string, unknown>
+) => boolean;
+
+/**
+ * Validation function definition in catalog
+ */
+export interface ValidationFunctionDefinition {
+  /** The validation function */
+  validate: ValidationFunction;
+  /** Description for AI */
+  description?: string;
+}
+
+/**
+ * Built-in validation functions
+ */
+export const builtInValidationFunctions: Record<string, ValidationFunction> = {
+  /**
+   * Check if value is not null, undefined, or empty string
+   */
+  required: (value: unknown) => {
+    if (value === null || value === undefined) return false;
+    if (typeof value === 'string') return value.trim().length > 0;
+    if (Array.isArray(value)) return value.length > 0;
+    return true;
+  },
+
+  /**
+   * Check if value is a valid email address
+   */
+  email: (value: unknown) => {
+    if (typeof value !== 'string') return false;
+    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
+  },
+
+  /**
+   * Check minimum string length
+   */
+  minLength: (value: unknown, args?: Record<string, unknown>) => {
+    if (typeof value !== 'string') return false;
+    const min = args?.min;
+    if (typeof min !== 'number') return false;
+    return value.length >= min;
+  },
+
+  /**
+   * Check maximum string length
+   */
+  maxLength: (value: unknown, args?: Record<string, unknown>) => {
+    if (typeof value !== 'string') return false;
+    const max = args?.max;
+    if (typeof max !== 'number') return false;
+    return value.length <= max;
+  },
+
+  /**
+   * Check if string matches a regex pattern
+   */
+  pattern: (value: unknown, args?: Record<string, unknown>) => {
+    if (typeof value !== 'string') return false;
+    const pattern = args?.pattern;
+    if (typeof pattern !== 'string') return false;
+    try {
+      return new RegExp(pattern).test(value);
+    } catch {
+      return false;
+    }
+  },
+
+  /**
+   * Check minimum numeric value
+   */
+  min: (value: unknown, args?: Record<string, unknown>) => {
+    if (typeof value !== 'number') return false;
+    const min = args?.min;
+    if (typeof min !== 'number') return false;
+    return value >= min;
+  },
+
+  /**
+   * Check maximum numeric value
+   */
+  max: (value: unknown, args?: Record<string, unknown>) => {
+    if (typeof value !== 'number') return false;
+    const max = args?.max;
+    if (typeof max !== 'number') return false;
+    return value <= max;
+  },
+
+  /**
+   * Check if value is a number
+   */
+  numeric: (value: unknown) => {
+    if (typeof value === 'number') return !isNaN(value);
+    if (typeof value === 'string') return !isNaN(parseFloat(value));
+    return false;
+  },
+
+  /**
+   * Check if value is a valid URL
+   */
+  url: (value: unknown) => {
+    if (typeof value !== 'string') return false;
+    try {
+      new URL(value);
+      return true;
+    } catch {
+      return false;
+    }
+  },
+
+  /**
+   * Check if value matches another field
+   */
+  matches: (value: unknown, args?: Record<string, unknown>) => {
+    const other = args?.other;
+    return value === other;
+  },
+};
+
+/**
+ * Validation result for a single check
+ */
+export interface ValidationCheckResult {
+  fn: string;
+  valid: boolean;
+  message: string;
+}
+
+/**
+ * Full validation result for a field
+ */
+export interface ValidationResult {
+  valid: boolean;
+  errors: string[];
+  checks: ValidationCheckResult[];
+}
+
+/**
+ * Context for running validation
+ */
+export interface ValidationContext {
+  /** Current value to validate */
+  value: unknown;
+  /** Full data model for resolving paths */
+  dataModel: DataModel;
+  /** Custom validation functions from catalog */
+  customFunctions?: Record<string, ValidationFunction>;
+}
+
+/**
+ * Run a single validation check
+ */
+export function runValidationCheck(
+  check: ValidationCheck,
+  ctx: ValidationContext
+): ValidationCheckResult {
+  const { value, dataModel, customFunctions } = ctx;
+  
+  // Resolve args
+  const resolvedArgs: Record<string, unknown> = {};
+  if (check.args) {
+    for (const [key, argValue] of Object.entries(check.args)) {
+      resolvedArgs[key] = resolveDynamicValue(argValue, dataModel);
+    }
+  }
+  
+  // Find the validation function
+  const fn = builtInValidationFunctions[check.fn] ?? customFunctions?.[check.fn];
+  
+  if (!fn) {
+    console.warn(`Unknown validation function: ${check.fn}`);
+    return {
+      fn: check.fn,
+      valid: true, // Don't fail on unknown functions
+      message: check.message,
+    };
+  }
+  
+  const valid = fn(value, resolvedArgs);
+  
+  return {
+    fn: check.fn,
+    valid,
+    message: check.message,
+  };
+}
+
+/**
+ * Run all validation checks for a field
+ */
+export function runValidation(
+  config: ValidationConfig,
+  ctx: ValidationContext & { authState?: { isSignedIn: boolean } }
+): ValidationResult {
+  const checks: ValidationCheckResult[] = [];
+  const errors: string[] = [];
+  
+  // Check if validation is enabled
+  if (config.enabled) {
+    const enabled = evaluateLogicExpression(config.enabled, {
+      dataModel: ctx.dataModel,
+      authState: ctx.authState,
+    });
+    if (!enabled) {
+      return { valid: true, errors: [], checks: [] };
+    }
+  }
+  
+  // Run each check
+  if (config.checks) {
+    for (const check of config.checks) {
+      const result = runValidationCheck(check, ctx);
+      checks.push(result);
+      if (!result.valid) {
+        errors.push(result.message);
+      }
+    }
+  }
+  
+  return {
+    valid: errors.length === 0,
+    errors,
+    checks,
+  };
+}
+
+/**
+ * Helper to create validation checks
+ */
+export const check = {
+  required: (message = 'This field is required'): ValidationCheck => ({
+    fn: 'required',
+    message,
+  }),
+  
+  email: (message = 'Invalid email address'): ValidationCheck => ({
+    fn: 'email',
+    message,
+  }),
+  
+  minLength: (min: number, message?: string): ValidationCheck => ({
+    fn: 'minLength',
+    args: { min },
+    message: message ?? `Must be at least ${min} characters`,
+  }),
+  
+  maxLength: (max: number, message?: string): ValidationCheck => ({
+    fn: 'maxLength',
+    args: { max },
+    message: message ?? `Must be at most ${max} characters`,
+  }),
+  
+  pattern: (pattern: string, message = 'Invalid format'): ValidationCheck => ({
+    fn: 'pattern',
+    args: { pattern },
+    message,
+  }),
+  
+  min: (min: number, message?: string): ValidationCheck => ({
+    fn: 'min',
+    args: { min },
+    message: message ?? `Must be at least ${min}`,
+  }),
+  
+  max: (max: number, message?: string): ValidationCheck => ({
+    fn: 'max',
+    args: { max },
+    message: message ?? `Must be at most ${max}`,
+  }),
+  
+  url: (message = 'Invalid URL'): ValidationCheck => ({
+    fn: 'url',
+    message,
+  }),
+  
+  matches: (otherPath: string, message = 'Fields must match'): ValidationCheck => ({
+    fn: 'matches',
+    args: { other: { path: otherPath } },
+    message,
+  }),
+};

+ 231 - 0
packages/core/src/visibility.ts

@@ -0,0 +1,231 @@
+import { z } from 'zod';
+import type {
+  VisibilityCondition,
+  LogicExpression,
+  DataModel,
+  AuthState,
+  DynamicValue,
+} from './types';
+import { resolveDynamicValue, DynamicValueSchema } from './types';
+
+// Dynamic value schema for comparisons (number-focused)
+const DynamicNumberValueSchema = z.union([
+  z.number(),
+  z.object({ path: z.string() }),
+]);
+
+/**
+ * Logic expression schema (recursive)
+ * Using a more permissive schema that aligns with runtime behavior
+ */
+export const LogicExpressionSchema: z.ZodType<LogicExpression> = z.lazy(() =>
+  z.union([
+    z.object({ and: z.array(LogicExpressionSchema) }),
+    z.object({ or: z.array(LogicExpressionSchema) }),
+    z.object({ not: LogicExpressionSchema }),
+    z.object({ path: z.string() }),
+    z.object({ eq: z.tuple([DynamicValueSchema, DynamicValueSchema]) }),
+    z.object({ neq: z.tuple([DynamicValueSchema, DynamicValueSchema]) }),
+    z.object({ gt: z.tuple([DynamicNumberValueSchema, DynamicNumberValueSchema]) }),
+    z.object({ gte: z.tuple([DynamicNumberValueSchema, DynamicNumberValueSchema]) }),
+    z.object({ lt: z.tuple([DynamicNumberValueSchema, DynamicNumberValueSchema]) }),
+    z.object({ lte: z.tuple([DynamicNumberValueSchema, DynamicNumberValueSchema]) }),
+  ])
+) as z.ZodType<LogicExpression>;
+
+/**
+ * Visibility condition schema
+ */
+export const VisibilityConditionSchema: z.ZodType<VisibilityCondition> = z.union([
+  z.boolean(),
+  z.object({ path: z.string() }),
+  z.object({ auth: z.enum(['signedIn', 'signedOut']) }),
+  LogicExpressionSchema,
+]);
+
+/**
+ * Context for evaluating visibility
+ */
+export interface VisibilityContext {
+  dataModel: DataModel;
+  authState?: AuthState;
+}
+
+/**
+ * Evaluate a logic expression against data and auth state
+ */
+export function evaluateLogicExpression(
+  expr: LogicExpression,
+  ctx: VisibilityContext
+): boolean {
+  const { dataModel } = ctx;
+
+  // AND expression
+  if ('and' in expr) {
+    return expr.and.every((subExpr) => evaluateLogicExpression(subExpr, ctx));
+  }
+
+  // OR expression
+  if ('or' in expr) {
+    return expr.or.some((subExpr) => evaluateLogicExpression(subExpr, ctx));
+  }
+
+  // NOT expression
+  if ('not' in expr) {
+    return !evaluateLogicExpression(expr.not, ctx);
+  }
+
+  // Path expression (resolve to boolean)
+  if ('path' in expr) {
+    const value = resolveDynamicValue({ path: expr.path }, dataModel);
+    return Boolean(value);
+  }
+
+  // Equality comparison
+  if ('eq' in expr) {
+    const [left, right] = expr.eq;
+    const leftValue = resolveDynamicValue(left, dataModel);
+    const rightValue = resolveDynamicValue(right, dataModel);
+    return leftValue === rightValue;
+  }
+
+  // Not equal comparison
+  if ('neq' in expr) {
+    const [left, right] = expr.neq;
+    const leftValue = resolveDynamicValue(left, dataModel);
+    const rightValue = resolveDynamicValue(right, dataModel);
+    return leftValue !== rightValue;
+  }
+
+  // Greater than
+  if ('gt' in expr) {
+    const [left, right] = expr.gt;
+    const leftValue = resolveDynamicValue(left as DynamicValue<number>, dataModel);
+    const rightValue = resolveDynamicValue(right as DynamicValue<number>, dataModel);
+    if (typeof leftValue === 'number' && typeof rightValue === 'number') {
+      return leftValue > rightValue;
+    }
+    return false;
+  }
+
+  // Greater than or equal
+  if ('gte' in expr) {
+    const [left, right] = expr.gte;
+    const leftValue = resolveDynamicValue(left as DynamicValue<number>, dataModel);
+    const rightValue = resolveDynamicValue(right as DynamicValue<number>, dataModel);
+    if (typeof leftValue === 'number' && typeof rightValue === 'number') {
+      return leftValue >= rightValue;
+    }
+    return false;
+  }
+
+  // Less than
+  if ('lt' in expr) {
+    const [left, right] = expr.lt;
+    const leftValue = resolveDynamicValue(left as DynamicValue<number>, dataModel);
+    const rightValue = resolveDynamicValue(right as DynamicValue<number>, dataModel);
+    if (typeof leftValue === 'number' && typeof rightValue === 'number') {
+      return leftValue < rightValue;
+    }
+    return false;
+  }
+
+  // Less than or equal
+  if ('lte' in expr) {
+    const [left, right] = expr.lte;
+    const leftValue = resolveDynamicValue(left as DynamicValue<number>, dataModel);
+    const rightValue = resolveDynamicValue(right as DynamicValue<number>, dataModel);
+    if (typeof leftValue === 'number' && typeof rightValue === 'number') {
+      return leftValue <= rightValue;
+    }
+    return false;
+  }
+
+  return false;
+}
+
+/**
+ * Evaluate a visibility condition
+ */
+export function evaluateVisibility(
+  condition: VisibilityCondition | undefined,
+  ctx: VisibilityContext
+): boolean {
+  // No condition = visible
+  if (condition === undefined) {
+    return true;
+  }
+
+  // Boolean literal
+  if (typeof condition === 'boolean') {
+    return condition;
+  }
+
+  // Path reference
+  if ('path' in condition && !('and' in condition) && !('or' in condition)) {
+    const value = resolveDynamicValue({ path: condition.path }, ctx.dataModel);
+    return Boolean(value);
+  }
+
+  // Auth condition
+  if ('auth' in condition) {
+    const isSignedIn = ctx.authState?.isSignedIn ?? false;
+    if (condition.auth === 'signedIn') {
+      return isSignedIn;
+    }
+    if (condition.auth === 'signedOut') {
+      return !isSignedIn;
+    }
+    return false;
+  }
+
+  // Logic expression
+  return evaluateLogicExpression(condition as LogicExpression, ctx);
+}
+
+/**
+ * Helper to create visibility conditions
+ */
+export const visibility = {
+  /** Always visible */
+  always: true as const,
+  
+  /** Never visible */
+  never: false as const,
+  
+  /** Visible when path is truthy */
+  when: (path: string): VisibilityCondition => ({ path }),
+  
+  /** Visible when signed in */
+  signedIn: { auth: 'signedIn' } as const,
+  
+  /** Visible when signed out */
+  signedOut: { auth: 'signedOut' } as const,
+  
+  /** AND multiple conditions */
+  and: (...conditions: LogicExpression[]): LogicExpression => ({ and: conditions }),
+  
+  /** OR multiple conditions */
+  or: (...conditions: LogicExpression[]): LogicExpression => ({ or: conditions }),
+  
+  /** NOT a condition */
+  not: (condition: LogicExpression): LogicExpression => ({ not: condition }),
+  
+  /** Equality check */
+  eq: (left: DynamicValue, right: DynamicValue): LogicExpression => ({ eq: [left, right] }),
+  
+  /** Not equal check */
+  neq: (left: DynamicValue, right: DynamicValue): LogicExpression => ({ neq: [left, right] }),
+  
+  /** Greater than */
+  gt: (left: DynamicValue<number>, right: DynamicValue<number>): LogicExpression => ({ gt: [left, right] }),
+  
+  /** Greater than or equal */
+  gte: (left: DynamicValue<number>, right: DynamicValue<number>): LogicExpression => ({ gte: [left, right] }),
+  
+  /** Less than */
+  lt: (left: DynamicValue<number>, right: DynamicValue<number>): LogicExpression => ({ lt: [left, right] }),
+  
+  /** Less than or equal */
+  lte: (left: DynamicValue<number>, right: DynamicValue<number>): LogicExpression => ({ lte: [left, right] }),
+};

+ 9 - 0
packages/core/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/core/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+  entry: ['src/index.ts'],
+  format: ['cjs', 'esm'],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ['zod'],
+});

+ 3 - 0
packages/eslint-config/README.md

@@ -0,0 +1,3 @@
+# `@turbo/eslint-config`
+
+Collection of internal eslint configurations.

+ 32 - 0
packages/eslint-config/base.js

@@ -0,0 +1,32 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import turboPlugin from "eslint-plugin-turbo";
+import tseslint from "typescript-eslint";
+import onlyWarn from "eslint-plugin-only-warn";
+
+/**
+ * A shared ESLint configuration for the repository.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const config = [
+  js.configs.recommended,
+  eslintConfigPrettier,
+  ...tseslint.configs.recommended,
+  {
+    plugins: {
+      turbo: turboPlugin,
+    },
+    rules: {
+      "turbo/no-undeclared-env-vars": "warn",
+    },
+  },
+  {
+    plugins: {
+      onlyWarn,
+    },
+  },
+  {
+    ignores: ["dist/**"],
+  },
+];

+ 57 - 0
packages/eslint-config/next.js

@@ -0,0 +1,57 @@
+import js from "@eslint/js";
+import { globalIgnores } from "eslint/config";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import pluginNext from "@next/eslint-plugin-next";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use Next.js.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const nextJsConfig = [
+  ...baseConfig,
+  js.configs.recommended,
+  eslintConfigPrettier,
+  ...tseslint.configs.recommended,
+  globalIgnores([
+    // Default ignores of eslint-config-next:
+    ".next/**",
+    "out/**",
+    "build/**",
+    "next-env.d.ts",
+  ]),
+  {
+    ...pluginReact.configs.flat.recommended,
+    languageOptions: {
+      ...pluginReact.configs.flat.recommended.languageOptions,
+      globals: {
+        ...globals.serviceworker,
+      },
+    },
+  },
+  {
+    plugins: {
+      "@next/next": pluginNext,
+    },
+    rules: {
+      ...pluginNext.configs.recommended.rules,
+      ...pluginNext.configs["core-web-vitals"].rules,
+    },
+  },
+  {
+    plugins: {
+      "react-hooks": pluginReactHooks,
+    },
+    settings: { react: { version: "detect" } },
+    rules: {
+      ...pluginReactHooks.configs.recommended.rules,
+      // React scope no longer necessary with new JSX transform.
+      "react/react-in-jsx-scope": "off",
+    },
+  },
+];

+ 24 - 0
packages/eslint-config/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "@repo/eslint-config",
+  "version": "0.0.0",
+  "type": "module",
+  "private": true,
+  "exports": {
+    "./base": "./base.js",
+    "./next-js": "./next.js",
+    "./react-internal": "./react-internal.js"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.39.1",
+    "@next/eslint-plugin-next": "^15.5.0",
+    "eslint": "^9.39.1",
+    "eslint-config-prettier": "^10.1.1",
+    "eslint-plugin-only-warn": "^1.1.0",
+    "eslint-plugin-react": "^7.37.5",
+    "eslint-plugin-react-hooks": "^5.2.0",
+    "eslint-plugin-turbo": "^2.7.1",
+    "globals": "^16.5.0",
+    "typescript": "^5.9.2",
+    "typescript-eslint": "^8.50.0"
+  }
+}

+ 39 - 0
packages/eslint-config/react-internal.js

@@ -0,0 +1,39 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use React.
+ *
+ * @type {import("eslint").Linter.Config[]} */
+export const config = [
+  ...baseConfig,
+  js.configs.recommended,
+  eslintConfigPrettier,
+  ...tseslint.configs.recommended,
+  pluginReact.configs.flat.recommended,
+  {
+    languageOptions: {
+      ...pluginReact.configs.flat.recommended.languageOptions,
+      globals: {
+        ...globals.serviceworker,
+        ...globals.browser,
+      },
+    },
+  },
+  {
+    plugins: {
+      "react-hooks": pluginReactHooks,
+    },
+    settings: { react: { version: "detect" } },
+    rules: {
+      ...pluginReactHooks.configs.recommended.rules,
+      // React scope no longer necessary with new JSX transform.
+      "react/react-in-jsx-scope": "off",
+    },
+  },
+];

+ 238 - 0
packages/react/README.md

@@ -0,0 +1,238 @@
+# @json-render/react
+
+React renderer for @json-render/core - JSON becomes React components.
+
+## Features
+
+- **Visibility Filtering**: Components automatically show/hide based on visibility conditions
+- **Action Handling**: Built-in action execution with confirmation dialogs
+- **Validation**: Field validation with error display
+- **Data Binding**: Two-way data binding between UI and data model
+- **Streaming**: Progressive rendering from streamed UI trees
+
+## Installation
+
+```bash
+npm install @json-render/react @json-render/core
+# or
+pnpm add @json-render/react @json-render/core
+```
+
+## Quick Start
+
+### Basic Setup
+
+```tsx
+import { JSONUIProvider, Renderer, useUIStream } from '@json-render/react';
+
+// Define your component registry
+const registry = {
+  Card: ({ element, children }) => (
+    <div className="card">
+      <h3>{element.props.title}</h3>
+      {children}
+    </div>
+  ),
+  Button: ({ element, onAction }) => (
+    <button onClick={() => onAction?.(element.props.action)}>
+      {element.props.label}
+    </button>
+  ),
+};
+
+// Action handlers
+const actionHandlers = {
+  submit: async (params) => {
+    await api.submit(params);
+  },
+  export: (params) => {
+    download(params.format);
+  },
+};
+
+function App() {
+  const { tree, isStreaming, send, clear } = useUIStream({
+    api: '/api/generate',
+  });
+
+  return (
+    <JSONUIProvider
+      registry={registry}
+      initialData={{ user: { name: 'John' } }}
+      authState={{ isSignedIn: true }}
+      actionHandlers={actionHandlers}
+    >
+      <input
+        placeholder="Describe the UI..."
+        onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
+      />
+      <Renderer tree={tree} registry={registry} loading={isStreaming} />
+    </JSONUIProvider>
+  );
+}
+```
+
+### Using Contexts Directly
+
+```tsx
+import {
+  DataProvider,
+  VisibilityProvider,
+  ActionProvider,
+  ValidationProvider,
+  useData,
+  useVisibility,
+  useActions,
+  useFieldValidation,
+} from '@json-render/react';
+
+// Data context
+function MyComponent() {
+  const { data, get, set } = useData();
+  const value = get('/user/name');
+  
+  return (
+    <input
+      value={value}
+      onChange={(e) => set('/user/name', e.target.value)}
+    />
+  );
+}
+
+// Visibility context
+function ConditionalComponent({ visible }) {
+  const { isVisible } = useVisibility();
+  
+  if (!isVisible(visible)) {
+    return null;
+  }
+  
+  return <div>Visible content</div>;
+}
+
+// Action context
+function ActionButton({ action }) {
+  const { execute, loadingActions } = useActions();
+  
+  return (
+    <button
+      onClick={() => execute(action)}
+      disabled={loadingActions.has(action.name)}
+    >
+      {action.name}
+    </button>
+  );
+}
+
+// Validation context
+function ValidatedInput({ path, checks }) {
+  const { errors, validate, touch } = useFieldValidation(path, { checks });
+  const [value, setValue] = useDataBinding(path);
+  
+  return (
+    <div>
+      <input
+        value={value}
+        onChange={(e) => setValue(e.target.value)}
+        onBlur={() => { touch(); validate(); }}
+      />
+      {errors.map((err) => <span key={err}>{err}</span>)}
+    </div>
+  );
+}
+```
+
+### Streaming UI
+
+```tsx
+import { useUIStream } from '@json-render/react';
+
+function StreamingDemo() {
+  const {
+    tree,        // Current UI tree
+    isStreaming, // Whether currently streaming
+    error,       // Error if any
+    send,        // Send a prompt
+    clear,       // Clear the tree
+  } = useUIStream({
+    api: '/api/generate',
+    onComplete: (tree) => console.log('Done:', tree),
+    onError: (err) => console.error('Error:', err),
+  });
+
+  return (
+    <div>
+      <button onClick={() => send('Create a dashboard')}>
+        Generate
+      </button>
+      {isStreaming && <span>Generating...</span>}
+      {tree && <Renderer tree={tree} registry={registry} />}
+    </div>
+  );
+}
+```
+
+## API Reference
+
+### Providers
+
+- `JSONUIProvider` - Combined provider for all contexts
+- `DataProvider` - Data model context
+- `VisibilityProvider` - Visibility evaluation context
+- `ActionProvider` - Action execution context
+- `ValidationProvider` - Validation context
+
+### Hooks
+
+- `useData()` - Access data model
+- `useDataValue(path)` - Get a single value
+- `useDataBinding(path)` - Two-way binding like useState
+- `useVisibility()` - Access visibility evaluation
+- `useIsVisible(condition)` - Check if condition is visible
+- `useActions()` - Access action execution
+- `useAction(action)` - Execute a specific action
+- `useValidation()` - Access validation context
+- `useFieldValidation(path, config)` - Field-level validation
+
+### Components
+
+- `Renderer` - Render a UI tree
+- `ConfirmDialog` - Default confirmation dialog
+
+### Utilities
+
+- `useUIStream(options)` - Hook for streaming UI generation
+- `flatToTree(elements)` - Convert flat list to tree
+
+## Component Props
+
+Components in your registry receive these props:
+
+```typescript
+interface ComponentRenderProps<P = Record<string, unknown>> {
+  element: UIElement<string, P>;  // The element definition
+  children?: ReactNode;           // Rendered children
+  onAction?: (action: Action) => void;  // Action callback
+  loading?: boolean;              // Streaming in progress
+}
+```
+
+## Example Component
+
+```tsx
+function MetricComponent({ element }: ComponentRenderProps) {
+  const { label, valuePath, format } = element.props;
+  const value = useDataValue(valuePath);
+  
+  const formatted = format === 'currency'
+    ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
+    : String(value);
+  
+  return (
+    <div className="metric">
+      <span className="label">{label}</span>
+      <span className="value">{formatted}</span>
+    </div>
+  );
+}
+```

+ 35 - 0
packages/react/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "@json-render/react",
+  "version": "0.1.0",
+  "description": "React renderer for @json-render/core. JSON becomes React components.",
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "@types/react": "^18.2.0",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "react": "^18.0.0 || ^19.0.0"
+  }
+}

+ 304 - 0
packages/react/src/contexts/actions.tsx

@@ -0,0 +1,304 @@
+'use client';
+
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from 'react';
+import {
+  resolveAction,
+  executeAction,
+  type Action,
+  type ActionHandler,
+  type ActionConfirm,
+  type ResolvedAction,
+} from '@json-render/core';
+import { useData } from './data';
+
+/**
+ * Pending confirmation state
+ */
+export interface PendingConfirmation {
+  /** The resolved action */
+  action: ResolvedAction;
+  /** The action handler */
+  handler: ActionHandler;
+  /** Resolve callback */
+  resolve: () => void;
+  /** Reject callback */
+  reject: () => void;
+}
+
+/**
+ * Action context value
+ */
+export interface ActionContextValue {
+  /** Registered action handlers */
+  handlers: Record<string, ActionHandler>;
+  /** Currently loading action names */
+  loadingActions: Set<string>;
+  /** Pending confirmation dialog */
+  pendingConfirmation: PendingConfirmation | null;
+  /** Execute an action */
+  execute: (action: Action) => Promise<void>;
+  /** Confirm the pending action */
+  confirm: () => void;
+  /** Cancel the pending action */
+  cancel: () => void;
+  /** Register an action handler */
+  registerHandler: (name: string, handler: ActionHandler) => void;
+}
+
+const ActionContext = createContext<ActionContextValue | null>(null);
+
+/**
+ * Props for ActionProvider
+ */
+export interface ActionProviderProps {
+  /** Initial action handlers */
+  handlers?: Record<string, ActionHandler>;
+  /** Navigation function */
+  navigate?: (path: string) => void;
+  children: ReactNode;
+}
+
+/**
+ * Provider for action execution
+ */
+export function ActionProvider({
+  handlers: initialHandlers = {},
+  navigate,
+  children,
+}: ActionProviderProps) {
+  const { data, set } = useData();
+  const [handlers, setHandlers] = useState<Record<string, ActionHandler>>(initialHandlers);
+  const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
+  const [pendingConfirmation, setPendingConfirmation] = useState<PendingConfirmation | null>(null);
+
+  const registerHandler = useCallback((name: string, handler: ActionHandler) => {
+    setHandlers((prev) => ({ ...prev, [name]: handler }));
+  }, []);
+
+  const execute = useCallback(
+    async (action: Action) => {
+      const resolved = resolveAction(action, data);
+      const handler = handlers[resolved.name];
+
+      if (!handler) {
+        console.warn(`No handler registered for action: ${resolved.name}`);
+        return;
+      }
+
+      // If confirmation is required, show dialog
+      if (resolved.confirm) {
+        return new Promise<void>((resolve, reject) => {
+          setPendingConfirmation({
+            action: resolved,
+            handler,
+            resolve: () => {
+              setPendingConfirmation(null);
+              resolve();
+            },
+            reject: () => {
+              setPendingConfirmation(null);
+              reject(new Error('Action cancelled'));
+            },
+          });
+        }).then(async () => {
+          setLoadingActions((prev) => new Set(prev).add(resolved.name));
+          try {
+            await executeAction({
+              action: resolved,
+              handler,
+              setData: set,
+              navigate,
+              executeAction: async (name) => {
+                const subAction: Action = { name };
+                await execute(subAction);
+              },
+            });
+          } finally {
+            setLoadingActions((prev) => {
+              const next = new Set(prev);
+              next.delete(resolved.name);
+              return next;
+            });
+          }
+        });
+      }
+
+      // Execute immediately
+      setLoadingActions((prev) => new Set(prev).add(resolved.name));
+      try {
+        await executeAction({
+          action: resolved,
+          handler,
+          setData: set,
+          navigate,
+          executeAction: async (name) => {
+            const subAction: Action = { name };
+            await execute(subAction);
+          },
+        });
+      } finally {
+        setLoadingActions((prev) => {
+          const next = new Set(prev);
+          next.delete(resolved.name);
+          return next;
+        });
+      }
+    },
+    [data, handlers, set, navigate]
+  );
+
+  const confirm = useCallback(() => {
+    pendingConfirmation?.resolve();
+  }, [pendingConfirmation]);
+
+  const cancel = useCallback(() => {
+    pendingConfirmation?.reject();
+  }, [pendingConfirmation]);
+
+  const value = useMemo<ActionContextValue>(
+    () => ({
+      handlers,
+      loadingActions,
+      pendingConfirmation,
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    }),
+    [handlers, loadingActions, pendingConfirmation, execute, confirm, cancel, registerHandler]
+  );
+
+  return (
+    <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
+  );
+}
+
+/**
+ * Hook to access action context
+ */
+export function useActions(): ActionContextValue {
+  const ctx = useContext(ActionContext);
+  if (!ctx) {
+    throw new Error('useActions must be used within an ActionProvider');
+  }
+  return ctx;
+}
+
+/**
+ * Hook to execute an action
+ */
+export function useAction(action: Action): {
+  execute: () => Promise<void>;
+  isLoading: boolean;
+} {
+  const { execute, loadingActions } = useActions();
+  const isLoading = loadingActions.has(action.name);
+
+  const executeAction = useCallback(() => execute(action), [execute, action]);
+
+  return { execute: executeAction, isLoading };
+}
+
+/**
+ * Props for ConfirmDialog component
+ */
+export interface ConfirmDialogProps {
+  /** The confirmation config */
+  confirm: ActionConfirm;
+  /** Called when confirmed */
+  onConfirm: () => void;
+  /** Called when cancelled */
+  onCancel: () => void;
+}
+
+/**
+ * Default confirmation dialog component
+ */
+export function ConfirmDialog({ confirm, onConfirm, onCancel }: ConfirmDialogProps) {
+  const isDanger = confirm.variant === 'danger';
+
+  return (
+    <div
+      style={{
+        position: 'fixed',
+        inset: 0,
+        backgroundColor: 'rgba(0, 0, 0, 0.5)',
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        zIndex: 50,
+      }}
+      onClick={onCancel}
+    >
+      <div
+        style={{
+          backgroundColor: 'white',
+          borderRadius: '8px',
+          padding: '24px',
+          maxWidth: '400px',
+          width: '100%',
+          boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
+        }}
+        onClick={(e) => e.stopPropagation()}
+      >
+        <h3
+          style={{
+            margin: '0 0 8px 0',
+            fontSize: '18px',
+            fontWeight: 600,
+          }}
+        >
+          {confirm.title}
+        </h3>
+        <p
+          style={{
+            margin: '0 0 24px 0',
+            color: '#6b7280',
+          }}
+        >
+          {confirm.message}
+        </p>
+        <div
+          style={{
+            display: 'flex',
+            gap: '12px',
+            justifyContent: 'flex-end',
+          }}
+        >
+          <button
+            onClick={onCancel}
+            style={{
+              padding: '8px 16px',
+              borderRadius: '6px',
+              border: '1px solid #d1d5db',
+              backgroundColor: 'white',
+              cursor: 'pointer',
+            }}
+          >
+            {confirm.cancelLabel ?? 'Cancel'}
+          </button>
+          <button
+            onClick={onConfirm}
+            style={{
+              padding: '8px 16px',
+              borderRadius: '6px',
+              border: 'none',
+              backgroundColor: isDanger ? '#dc2626' : '#3b82f6',
+              color: 'white',
+              cursor: 'pointer',
+            }}
+          >
+            {confirm.confirmLabel ?? 'Confirm'}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 137 - 0
packages/react/src/contexts/data.tsx

@@ -0,0 +1,137 @@
+'use client';
+
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from 'react';
+import {
+  getByPath,
+  setByPath,
+  type DataModel,
+  type AuthState,
+} from '@json-render/core';
+
+/**
+ * Data context value
+ */
+export interface DataContextValue {
+  /** The current data model */
+  data: DataModel;
+  /** Auth state for visibility evaluation */
+  authState?: AuthState;
+  /** Get a value by path */
+  get: (path: string) => unknown;
+  /** Set a value by path */
+  set: (path: string, value: unknown) => void;
+  /** Update multiple values at once */
+  update: (updates: Record<string, unknown>) => void;
+}
+
+const DataContext = createContext<DataContextValue | null>(null);
+
+/**
+ * Props for DataProvider
+ */
+export interface DataProviderProps {
+  /** Initial data model */
+  initialData?: DataModel;
+  /** Auth state */
+  authState?: AuthState;
+  /** Callback when data changes */
+  onDataChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+/**
+ * Provider for data model context
+ */
+export function DataProvider({
+  initialData = {},
+  authState,
+  onDataChange,
+  children,
+}: DataProviderProps) {
+  const [data, setData] = useState<DataModel>(initialData);
+
+  const get = useCallback(
+    (path: string) => getByPath(data, path),
+    [data]
+  );
+
+  const set = useCallback(
+    (path: string, value: unknown) => {
+      setData((prev) => {
+        const next = { ...prev };
+        setByPath(next, path, value);
+        return next;
+      });
+      onDataChange?.(path, value);
+    },
+    [onDataChange]
+  );
+
+  const update = useCallback(
+    (updates: Record<string, unknown>) => {
+      setData((prev) => {
+        const next = { ...prev };
+        for (const [path, value] of Object.entries(updates)) {
+          setByPath(next, path, value);
+          onDataChange?.(path, value);
+        }
+        return next;
+      });
+    },
+    [onDataChange]
+  );
+
+  const value = useMemo<DataContextValue>(
+    () => ({
+      data,
+      authState,
+      get,
+      set,
+      update,
+    }),
+    [data, authState, get, set, update]
+  );
+
+  return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
+}
+
+/**
+ * Hook to access the data context
+ */
+export function useData(): DataContextValue {
+  const ctx = useContext(DataContext);
+  if (!ctx) {
+    throw new Error('useData must be used within a DataProvider');
+  }
+  return ctx;
+}
+
+/**
+ * Hook to get a value from the data model
+ */
+export function useDataValue<T>(path: string): T | undefined {
+  const { get } = useData();
+  return get(path) as T | undefined;
+}
+
+/**
+ * Hook to get and set a value from the data model (like useState)
+ */
+export function useDataBinding<T>(
+  path: string
+): [T | undefined, (value: T) => void] {
+  const { get, set } = useData();
+  const value = get(path) as T | undefined;
+  const setValue = useCallback(
+    (newValue: T) => set(path, newValue),
+    [path, set]
+  );
+  return [value, setValue];
+}

+ 215 - 0
packages/react/src/contexts/validation.tsx

@@ -0,0 +1,215 @@
+'use client';
+
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from 'react';
+import {
+  runValidation,
+  type ValidationConfig,
+  type ValidationFunction,
+  type ValidationResult,
+} from '@json-render/core';
+import { useData } from './data';
+
+/**
+ * Field validation state
+ */
+export interface FieldValidationState {
+  /** Whether the field has been touched */
+  touched: boolean;
+  /** Whether the field has been validated */
+  validated: boolean;
+  /** Validation result */
+  result: ValidationResult | null;
+}
+
+/**
+ * Validation context value
+ */
+export interface ValidationContextValue {
+  /** Custom validation functions from catalog */
+  customFunctions: Record<string, ValidationFunction>;
+  /** Validation state by field path */
+  fieldStates: Record<string, FieldValidationState>;
+  /** Validate a field */
+  validate: (path: string, config: ValidationConfig) => ValidationResult;
+  /** Mark field as touched */
+  touch: (path: string) => void;
+  /** Clear validation for a field */
+  clear: (path: string) => void;
+  /** Validate all fields */
+  validateAll: () => boolean;
+  /** Register field config */
+  registerField: (path: string, config: ValidationConfig) => void;
+}
+
+const ValidationContext = createContext<ValidationContextValue | null>(null);
+
+/**
+ * Props for ValidationProvider
+ */
+export interface ValidationProviderProps {
+  /** Custom validation functions from catalog */
+  customFunctions?: Record<string, ValidationFunction>;
+  children: ReactNode;
+}
+
+/**
+ * Provider for validation
+ */
+export function ValidationProvider({
+  customFunctions = {},
+  children,
+}: ValidationProviderProps) {
+  const { data, authState } = useData();
+  const [fieldStates, setFieldStates] = useState<Record<string, FieldValidationState>>({});
+  const [fieldConfigs, setFieldConfigs] = useState<Record<string, ValidationConfig>>({});
+
+  const registerField = useCallback((path: string, config: ValidationConfig) => {
+    setFieldConfigs((prev) => ({ ...prev, [path]: config }));
+  }, []);
+
+  const validate = useCallback(
+    (path: string, config: ValidationConfig): ValidationResult => {
+      const value = data[path.split('/').filter(Boolean).join('.')];
+      const result = runValidation(config, {
+        value,
+        dataModel: data,
+        customFunctions,
+        authState,
+      });
+
+      setFieldStates((prev) => ({
+        ...prev,
+        [path]: {
+          touched: prev[path]?.touched ?? true,
+          validated: true,
+          result,
+        },
+      }));
+
+      return result;
+    },
+    [data, customFunctions, authState]
+  );
+
+  const touch = useCallback((path: string) => {
+    setFieldStates((prev) => ({
+      ...prev,
+      [path]: {
+        ...prev[path],
+        touched: true,
+        validated: prev[path]?.validated ?? false,
+        result: prev[path]?.result ?? null,
+      },
+    }));
+  }, []);
+
+  const clear = useCallback((path: string) => {
+    setFieldStates((prev) => {
+      const { [path]: _, ...rest } = prev;
+      return rest;
+    });
+  }, []);
+
+  const validateAll = useCallback(() => {
+    let allValid = true;
+
+    for (const [path, config] of Object.entries(fieldConfigs)) {
+      const result = validate(path, config);
+      if (!result.valid) {
+        allValid = false;
+      }
+    }
+
+    return allValid;
+  }, [fieldConfigs, validate]);
+
+  const value = useMemo<ValidationContextValue>(
+    () => ({
+      customFunctions,
+      fieldStates,
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    }),
+    [customFunctions, fieldStates, validate, touch, clear, validateAll, registerField]
+  );
+
+  return (
+    <ValidationContext.Provider value={value}>
+      {children}
+    </ValidationContext.Provider>
+  );
+}
+
+/**
+ * Hook to access validation context
+ */
+export function useValidation(): ValidationContextValue {
+  const ctx = useContext(ValidationContext);
+  if (!ctx) {
+    throw new Error('useValidation must be used within a ValidationProvider');
+  }
+  return ctx;
+}
+
+/**
+ * Hook to get validation state for a field
+ */
+export function useFieldValidation(
+  path: string,
+  config?: ValidationConfig
+): {
+  state: FieldValidationState;
+  validate: () => ValidationResult;
+  touch: () => void;
+  clear: () => void;
+  errors: string[];
+  isValid: boolean;
+} {
+  const {
+    fieldStates,
+    validate: validateField,
+    touch: touchField,
+    clear: clearField,
+    registerField,
+  } = useValidation();
+
+  // Register field on mount
+  React.useEffect(() => {
+    if (config) {
+      registerField(path, config);
+    }
+  }, [path, config, registerField]);
+
+  const state = fieldStates[path] ?? {
+    touched: false,
+    validated: false,
+    result: null,
+  };
+
+  const validate = useCallback(
+    () => validateField(path, config ?? { checks: [] }),
+    [path, config, validateField]
+  );
+
+  const touch = useCallback(() => touchField(path), [path, touchField]);
+  const clear = useCallback(() => clearField(path), [path, clearField]);
+
+  return {
+    state,
+    validate,
+    touch,
+    clear,
+    errors: state.result?.errors ?? [],
+    isValid: state.result?.valid ?? true,
+  };
+}

+ 79 - 0
packages/react/src/contexts/visibility.tsx

@@ -0,0 +1,79 @@
+'use client';
+
+import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
+import {
+  evaluateVisibility,
+  type VisibilityCondition,
+  type VisibilityContext as CoreVisibilityContext,
+} from '@json-render/core';
+import { useData } from './data';
+
+/**
+ * Visibility context value
+ */
+export interface VisibilityContextValue {
+  /** Evaluate a visibility condition */
+  isVisible: (condition: VisibilityCondition | undefined) => boolean;
+  /** The underlying visibility context */
+  ctx: CoreVisibilityContext;
+}
+
+const VisibilityContext = createContext<VisibilityContextValue | null>(null);
+
+/**
+ * Props for VisibilityProvider
+ */
+export interface VisibilityProviderProps {
+  children: ReactNode;
+}
+
+/**
+ * Provider for visibility evaluation
+ */
+export function VisibilityProvider({ children }: VisibilityProviderProps) {
+  const { data, authState } = useData();
+
+  const ctx: CoreVisibilityContext = useMemo(
+    () => ({
+      dataModel: data,
+      authState,
+    }),
+    [data, authState]
+  );
+
+  const isVisible = useMemo(
+    () => (condition: VisibilityCondition | undefined) =>
+      evaluateVisibility(condition, ctx),
+    [ctx]
+  );
+
+  const value = useMemo<VisibilityContextValue>(
+    () => ({ isVisible, ctx }),
+    [isVisible, ctx]
+  );
+
+  return (
+    <VisibilityContext.Provider value={value}>
+      {children}
+    </VisibilityContext.Provider>
+  );
+}
+
+/**
+ * Hook to access visibility evaluation
+ */
+export function useVisibility(): VisibilityContextValue {
+  const ctx = useContext(VisibilityContext);
+  if (!ctx) {
+    throw new Error('useVisibility must be used within a VisibilityProvider');
+  }
+  return ctx;
+}
+
+/**
+ * Hook to check if a condition is visible
+ */
+export function useIsVisible(condition: VisibilityCondition | undefined): boolean {
+  const { isVisible } = useVisibility();
+  return isVisible(condition);
+}

+ 254 - 0
packages/react/src/hooks.ts

@@ -0,0 +1,254 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import type { UITree, UIElement, JsonPatch } from '@json-render/core';
+import { setByPath } from '@json-render/core';
+
+/**
+ * Parse a single JSON patch line
+ */
+function parsePatchLine(line: string): JsonPatch | null {
+  try {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('//')) {
+      return null;
+    }
+    return JSON.parse(trimmed) as JsonPatch;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Apply a JSON patch to the current tree
+ */
+function applyPatch(tree: UITree, patch: JsonPatch): UITree {
+  const newTree = { ...tree, elements: { ...tree.elements } };
+
+  switch (patch.op) {
+    case 'set':
+    case 'add':
+    case 'replace': {
+      // Handle root path
+      if (patch.path === '/root') {
+        newTree.root = patch.value as string;
+        return newTree;
+      }
+
+      // Handle elements paths
+      if (patch.path.startsWith('/elements/')) {
+        const pathParts = patch.path.slice('/elements/'.length).split('/');
+        const elementKey = pathParts[0];
+
+        if (!elementKey) return newTree;
+
+        if (pathParts.length === 1) {
+          // Setting entire element
+          newTree.elements[elementKey] = patch.value as UIElement;
+        } else {
+          // Setting property of element
+          const element = newTree.elements[elementKey];
+          if (element) {
+            const propPath = '/' + pathParts.slice(1).join('/');
+            const newElement = { ...element };
+            setByPath(newElement as unknown as Record<string, unknown>, propPath, patch.value);
+            newTree.elements[elementKey] = newElement;
+          }
+        }
+      }
+      break;
+    }
+    case 'remove': {
+      if (patch.path.startsWith('/elements/')) {
+        const elementKey = patch.path.slice('/elements/'.length).split('/')[0];
+        if (elementKey) {
+          const { [elementKey]: _, ...rest } = newTree.elements;
+          newTree.elements = rest;
+        }
+      }
+      break;
+    }
+  }
+
+  return newTree;
+}
+
+/**
+ * Options for useUIStream
+ */
+export interface UseUIStreamOptions {
+  /** API endpoint */
+  api: string;
+  /** Callback when complete */
+  onComplete?: (tree: UITree) => void;
+  /** Callback on error */
+  onError?: (error: Error) => void;
+}
+
+/**
+ * Return type for useUIStream
+ */
+export interface UseUIStreamReturn {
+  /** Current UI tree */
+  tree: UITree | null;
+  /** Whether currently streaming */
+  isStreaming: boolean;
+  /** Error if any */
+  error: Error | null;
+  /** Send a prompt to generate UI */
+  send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
+  /** Clear the current tree */
+  clear: () => void;
+}
+
+/**
+ * Hook for streaming UI generation
+ */
+export function useUIStream({
+  api,
+  onComplete,
+  onError,
+}: UseUIStreamOptions): UseUIStreamReturn {
+  const [tree, setTree] = useState<UITree | null>(null);
+  const [isStreaming, setIsStreaming] = useState(false);
+  const [error, setError] = useState<Error | null>(null);
+  const abortControllerRef = useRef<AbortController | null>(null);
+
+  const clear = useCallback(() => {
+    setTree(null);
+    setError(null);
+  }, []);
+
+  const send = useCallback(
+    async (prompt: string, context?: Record<string, unknown>) => {
+      // Abort any existing request
+      abortControllerRef.current?.abort();
+      abortControllerRef.current = new AbortController();
+
+      setIsStreaming(true);
+      setError(null);
+
+      // Start with an empty tree
+      let currentTree: UITree = { root: '', elements: {} };
+      setTree(currentTree);
+
+      try {
+        const response = await fetch(api, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            prompt,
+            context,
+            currentTree,
+          }),
+          signal: abortControllerRef.current.signal,
+        });
+
+        if (!response.ok) {
+          throw new Error(`HTTP error: ${response.status}`);
+        }
+
+        const reader = response.body?.getReader();
+        if (!reader) {
+          throw new Error('No response body');
+        }
+
+        const decoder = new TextDecoder();
+        let buffer = '';
+
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) break;
+
+          buffer += decoder.decode(value, { stream: true });
+
+          // Process complete lines
+          const lines = buffer.split('\n');
+          buffer = lines.pop() ?? '';
+
+          for (const line of lines) {
+            const patch = parsePatchLine(line);
+            if (patch) {
+              currentTree = applyPatch(currentTree, patch);
+              setTree({ ...currentTree });
+            }
+          }
+        }
+
+        // Process any remaining buffer
+        if (buffer.trim()) {
+          const patch = parsePatchLine(buffer);
+          if (patch) {
+            currentTree = applyPatch(currentTree, patch);
+            setTree({ ...currentTree });
+          }
+        }
+
+        onComplete?.(currentTree);
+      } catch (err) {
+        if ((err as Error).name === 'AbortError') {
+          return;
+        }
+        const error = err instanceof Error ? err : new Error(String(err));
+        setError(error);
+        onError?.(error);
+      } finally {
+        setIsStreaming(false);
+      }
+    },
+    [api, onComplete, onError]
+  );
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      abortControllerRef.current?.abort();
+    };
+  }, []);
+
+  return {
+    tree,
+    isStreaming,
+    error,
+    send,
+    clear,
+  };
+}
+
+/**
+ * Convert a flat element list to a UITree
+ */
+export function flatToTree(
+  elements: Array<UIElement & { parentKey?: string | null }>
+): UITree {
+  const elementMap: Record<string, UIElement> = {};
+  let root = '';
+
+  // First pass: add all elements to map
+  for (const element of elements) {
+    elementMap[element.key] = {
+      key: element.key,
+      type: element.type,
+      props: element.props,
+      children: [],
+      visible: element.visible,
+    };
+  }
+
+  // Second pass: build parent-child relationships
+  for (const element of elements) {
+    if (element.parentKey) {
+      const parent = elementMap[element.parentKey];
+      if (parent) {
+        if (!parent.children) {
+          parent.children = [];
+        }
+        parent.children.push(element.key);
+      }
+    } else {
+      root = element.key;
+    }
+  }
+
+  return { root, elements: elementMap };
+}

+ 57 - 0
packages/react/src/index.ts

@@ -0,0 +1,57 @@
+// Contexts
+export {
+  DataProvider,
+  useData,
+  useDataValue,
+  useDataBinding,
+  type DataContextValue,
+  type DataProviderProps,
+} from './contexts/data';
+
+export {
+  VisibilityProvider,
+  useVisibility,
+  useIsVisible,
+  type VisibilityContextValue,
+  type VisibilityProviderProps,
+} from './contexts/visibility';
+
+export {
+  ActionProvider,
+  useActions,
+  useAction,
+  ConfirmDialog,
+  type ActionContextValue,
+  type ActionProviderProps,
+  type PendingConfirmation,
+  type ConfirmDialogProps,
+} from './contexts/actions';
+
+export {
+  ValidationProvider,
+  useValidation,
+  useFieldValidation,
+  type ValidationContextValue,
+  type ValidationProviderProps,
+  type FieldValidationState,
+} from './contexts/validation';
+
+// Renderer
+export {
+  Renderer,
+  JSONUIProvider,
+  createRendererFromCatalog,
+  type ComponentRenderProps,
+  type ComponentRenderer,
+  type ComponentRegistry,
+  type RendererProps,
+  type JSONUIProviderProps,
+} from './renderer';
+
+// Hooks
+export {
+  useUIStream,
+  flatToTree,
+  type UseUIStreamOptions,
+  type UseUIStreamReturn,
+} from './hooks';

+ 222 - 0
packages/react/src/renderer.tsx

@@ -0,0 +1,222 @@
+'use client';
+
+import React, { type ComponentType, type ReactNode, useMemo } from 'react';
+import type { UIElement, UITree, Action, Catalog, ComponentDefinition } from '@json-render/core';
+import { useIsVisible } from './contexts/visibility';
+import { useActions } from './contexts/actions';
+import { useData } from './contexts/data';
+
+/**
+ * Props passed to component renderers
+ */
+export interface ComponentRenderProps<P = Record<string, unknown>> {
+  /** The element being rendered */
+  element: UIElement<string, P>;
+  /** Rendered children */
+  children?: ReactNode;
+  /** Execute an action */
+  onAction?: (action: Action) => void;
+  /** Whether the parent is loading */
+  loading?: boolean;
+}
+
+/**
+ * Component renderer type
+ */
+export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<ComponentRenderProps<P>>;
+
+/**
+ * Registry of component renderers
+ */
+export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
+
+/**
+ * Props for the Renderer component
+ */
+export interface RendererProps {
+  /** The UI tree to render */
+  tree: UITree | null;
+  /** Component registry */
+  registry: ComponentRegistry;
+  /** Whether the tree is currently loading/streaming */
+  loading?: boolean;
+  /** Fallback component for unknown types */
+  fallback?: ComponentRenderer;
+}
+
+/**
+ * Element renderer component
+ */
+function ElementRenderer({
+  element,
+  tree,
+  registry,
+  loading,
+  fallback,
+}: {
+  element: UIElement;
+  tree: UITree;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}) {
+  const isVisible = useIsVisible(element.visible);
+  const { execute } = useActions();
+
+  // Don't render if not visible
+  if (!isVisible) {
+    return null;
+  }
+
+  // Get the component renderer
+  const Component = registry[element.type] ?? fallback;
+
+  if (!Component) {
+    console.warn(`No renderer for component type: ${element.type}`);
+    return null;
+  }
+
+  // Render children
+  const children = element.children?.map((childKey) => {
+    const childElement = tree.elements[childKey];
+    if (!childElement) {
+      return null;
+    }
+    return (
+      <ElementRenderer
+        key={childKey}
+        element={childElement}
+        tree={tree}
+        registry={registry}
+        loading={loading}
+        fallback={fallback}
+      />
+    );
+  });
+
+  return (
+    <Component
+      element={element}
+      onAction={execute}
+      loading={loading}
+    >
+      {children}
+    </Component>
+  );
+}
+
+/**
+ * Main renderer component
+ */
+export function Renderer({ tree, registry, loading, fallback }: RendererProps) {
+  if (!tree || !tree.root) {
+    return null;
+  }
+
+  const rootElement = tree.elements[tree.root];
+  if (!rootElement) {
+    return null;
+  }
+
+  return (
+    <ElementRenderer
+      element={rootElement}
+      tree={tree}
+      registry={registry}
+      loading={loading}
+      fallback={fallback}
+    />
+  );
+}
+
+/**
+ * Props for JSONUIProvider
+ */
+export interface JSONUIProviderProps {
+  /** Component registry */
+  registry: ComponentRegistry;
+  /** Initial data model */
+  initialData?: Record<string, unknown>;
+  /** Auth state */
+  authState?: { isSignedIn: boolean; user?: Record<string, unknown> };
+  /** Action handlers */
+  actionHandlers?: Record<string, (params: Record<string, unknown>) => Promise<unknown> | unknown>;
+  /** Navigation function */
+  navigate?: (path: string) => void;
+  /** Custom validation functions */
+  validationFunctions?: Record<string, (value: unknown, args?: Record<string, unknown>) => boolean>;
+  /** Callback when data changes */
+  onDataChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+// Import the providers
+import { DataProvider } from './contexts/data';
+import { VisibilityProvider } from './contexts/visibility';
+import { ActionProvider } from './contexts/actions';
+import { ValidationProvider } from './contexts/validation';
+import { ConfirmDialog } from './contexts/actions';
+
+/**
+ * Combined provider for all JSONUI contexts
+ */
+export function JSONUIProvider({
+  registry,
+  initialData,
+  authState,
+  actionHandlers,
+  navigate,
+  validationFunctions,
+  onDataChange,
+  children,
+}: JSONUIProviderProps) {
+  return (
+    <DataProvider
+      initialData={initialData}
+      authState={authState}
+      onDataChange={onDataChange}
+    >
+      <VisibilityProvider>
+        <ActionProvider handlers={actionHandlers} navigate={navigate}>
+          <ValidationProvider customFunctions={validationFunctions}>
+            {children}
+            <ConfirmationDialogManager />
+          </ValidationProvider>
+        </ActionProvider>
+      </VisibilityProvider>
+    </DataProvider>
+  );
+}
+
+/**
+ * Renders the confirmation dialog when needed
+ */
+function ConfirmationDialogManager() {
+  const { pendingConfirmation, confirm, cancel } = useActions();
+
+  if (!pendingConfirmation?.action.confirm) {
+    return null;
+  }
+
+  return (
+    <ConfirmDialog
+      confirm={pendingConfirmation.action.confirm}
+      onConfirm={confirm}
+      onCancel={cancel}
+    />
+  );
+}
+
+/**
+ * Helper to create a renderer component from a catalog
+ */
+export function createRendererFromCatalog<
+  C extends Catalog<Record<string, ComponentDefinition>>
+>(
+  _catalog: C,
+  registry: ComponentRegistry
+): ComponentType<Omit<RendererProps, 'registry'>> {
+  return function CatalogRenderer(props: Omit<RendererProps, 'registry'>) {
+    return <Renderer {...props} registry={registry} />;
+  };
+}

+ 9 - 0
packages/react/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/react/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+  entry: ['src/index.ts'],
+  format: ['cjs', 'esm'],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ['react', 'react-dom', '@json-render/core'],
+});

+ 19 - 0
packages/typescript-config/base.json

@@ -0,0 +1,19 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "declaration": true,
+    "declarationMap": true,
+    "esModuleInterop": true,
+    "incremental": false,
+    "isolatedModules": true,
+    "lib": ["es2022", "DOM", "DOM.Iterable"],
+    "module": "NodeNext",
+    "moduleDetection": "force",
+    "moduleResolution": "NodeNext",
+    "noUncheckedIndexedAccess": true,
+    "resolveJsonModule": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "target": "ES2022"
+  }
+}

+ 12 - 0
packages/typescript-config/nextjs.json

@@ -0,0 +1,12 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "./base.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "allowJs": true,
+    "jsx": "preserve",
+    "noEmit": true
+  }
+}

+ 9 - 0
packages/typescript-config/package.json

@@ -0,0 +1,9 @@
+{
+  "name": "@repo/typescript-config",
+  "version": "0.0.0",
+  "private": true,
+  "license": "MIT",
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 7 - 0
packages/typescript-config/react-library.json

@@ -0,0 +1,7 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "./base.json",
+  "compilerOptions": {
+    "jsx": "react-jsx"
+  }
+}

+ 4 - 0
packages/ui/eslint.config.mjs

@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/react-internal";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;

+ 26 - 0
packages/ui/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@repo/ui",
+  "version": "0.0.0",
+  "private": true,
+  "exports": {
+    "./*": "./src/*.tsx"
+  },
+  "scripts": {
+    "lint": "eslint . --max-warnings 0",
+    "generate:component": "turbo gen react-component",
+    "check-types": "tsc --noEmit"
+  },
+  "devDependencies": {
+    "@repo/eslint-config": "workspace:*",
+    "@repo/typescript-config": "workspace:*",
+    "@types/node": "^22.15.3",
+    "@types/react": "19.2.2",
+    "@types/react-dom": "19.2.2",
+    "eslint": "^9.39.1",
+    "typescript": "5.9.2"
+  },
+  "dependencies": {
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0"
+  }
+}

+ 20 - 0
packages/ui/src/button.tsx

@@ -0,0 +1,20 @@
+"use client";
+
+import { ReactNode } from "react";
+
+interface ButtonProps {
+  children: ReactNode;
+  className?: string;
+  appName: string;
+}
+
+export const Button = ({ children, className, appName }: ButtonProps) => {
+  return (
+    <button
+      className={className}
+      onClick={() => alert(`Hello from your ${appName} app!`)}
+    >
+      {children}
+    </button>
+  );
+};

+ 27 - 0
packages/ui/src/card.tsx

@@ -0,0 +1,27 @@
+import { type JSX } from "react";
+
+export function Card({
+  className,
+  title,
+  children,
+  href,
+}: {
+  className?: string;
+  title: string;
+  children: React.ReactNode;
+  href: string;
+}): JSX.Element {
+  return (
+    <a
+      className={className}
+      href={`${href}?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo"`}
+      rel="noopener noreferrer"
+      target="_blank"
+    >
+      <h2>
+        {title} <span>-&gt;</span>
+      </h2>
+      <p>{children}</p>
+    </a>
+  );
+}

+ 11 - 0
packages/ui/src/code.tsx

@@ -0,0 +1,11 @@
+import { type JSX } from "react";
+
+export function Code({
+  children,
+  className,
+}: {
+  children: React.ReactNode;
+  className?: string;
+}): JSX.Element {
+  return <code className={className}>{children}</code>;
+}

+ 8 - 0
packages/ui/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů