Răsfoiți Sursa

feat: add Vue renderer (#162)

* feat: vue support

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
Anthony Fu 4 luni în urmă
părinte
comite
db3a8b41e9
55 a modificat fișierele cu 7253 adăugiri și 36 ștergeri
  1. 27 3
      README.md
  2. 246 0
      apps/web/app/(main)/docs/api/vue/page.mdx
  3. 12 9
      apps/web/app/(main)/docs/installation/page.mdx
  4. 11 0
      apps/web/lib/docs-navigation.ts
  5. 1 0
      apps/web/lib/page-titles.ts
  6. 31 0
      examples/vite-renderers/index.html
  7. 27 0
      examples/vite-renderers/package.json
  8. 30 0
      examples/vite-renderers/src/main.ts
  9. 51 0
      examples/vite-renderers/src/react/App.tsx
  10. 5 0
      examples/vite-renderers/src/react/catalog.ts
  11. 15 0
      examples/vite-renderers/src/react/mount.tsx
  12. 155 0
      examples/vite-renderers/src/react/registry.tsx
  13. 99 0
      examples/vite-renderers/src/shared/catalog-def.ts
  14. 50 0
      examples/vite-renderers/src/shared/handlers.ts
  15. 248 0
      examples/vite-renderers/src/shared/styles.css
  16. 126 0
      examples/vite-renderers/src/spec.ts
  17. 19 0
      examples/vite-renderers/src/vue/App.vue
  18. 25 0
      examples/vite-renderers/src/vue/DemoRenderer.vue
  19. 5 0
      examples/vite-renderers/src/vue/catalog.ts
  20. 15 0
      examples/vite-renderers/src/vue/mount.ts
  21. 166 0
      examples/vite-renderers/src/vue/registry.ts
  22. 17 0
      examples/vite-renderers/tsconfig.json
  23. 7 0
      examples/vite-renderers/vite.config.ts
  24. 26 0
      examples/vue/index.html
  25. 22 0
      examples/vue/package.json
  26. 14 0
      examples/vue/src/App.vue
  27. 68 0
      examples/vue/src/DemoRenderer.vue
  28. 92 0
      examples/vue/src/lib/catalog.ts
  29. 213 0
      examples/vue/src/lib/registry.ts
  30. 138 0
      examples/vue/src/lib/spec.ts
  31. 4 0
      examples/vue/src/main.ts
  32. 17 0
      examples/vue/tsconfig.json
  33. 6 0
      examples/vue/vite.config.ts
  34. 496 0
      packages/vue/README.md
  35. 66 0
      packages/vue/package.json
  36. 152 0
      packages/vue/src/catalog-types.ts
  37. 106 0
      packages/vue/src/composables/actions.test.ts
  38. 462 0
      packages/vue/src/composables/actions.ts
  39. 51 0
      packages/vue/src/composables/repeat-scope.ts
  40. 139 0
      packages/vue/src/composables/state.test.ts
  41. 216 0
      packages/vue/src/composables/state.ts
  42. 209 0
      packages/vue/src/composables/validation.test.ts
  43. 267 0
      packages/vue/src/composables/validation.ts
  44. 97 0
      packages/vue/src/composables/visibility.test.ts
  45. 68 0
      packages/vue/src/composables/visibility.ts
  46. 347 0
      packages/vue/src/hooks.test.ts
  47. 834 0
      packages/vue/src/hooks.ts
  48. 99 0
      packages/vue/src/index.ts
  49. 200 0
      packages/vue/src/renderer.test.ts
  50. 834 0
      packages/vue/src/renderer.ts
  51. 104 0
      packages/vue/src/schema.ts
  52. 9 0
      packages/vue/tsconfig.json
  53. 10 0
      packages/vue/tsup.config.ts
  54. 496 22
      pnpm-lock.yaml
  55. 3 2
      vitest.config.ts

+ 27 - 3
README.md

@@ -5,15 +5,18 @@
 Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
 Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
 
 
 ```bash
 ```bash
+# for React
 npm install @json-render/core @json-render/react
 npm install @json-render/core @json-render/react
-# pre-built shadcn/ui components
+# for React with pre-built shadcn/ui components
 npm install @json-render/shadcn
 npm install @json-render/shadcn
-# or for mobile
+# or for React Native
 npm install @json-render/core @json-render/react-native
 npm install @json-render/core @json-render/react-native
 # or for video
 # or for video
 npm install @json-render/core @json-render/remotion
 npm install @json-render/core @json-render/remotion
 # or for PDF documents
 # or for PDF documents
 npm install @json-render/core @json-render/react-pdf
 npm install @json-render/core @json-render/react-pdf
+# or for Vue
+npm install @json-render/core @json-render/vue
 ```
 ```
 
 
 ## Why json-render?
 ## Why json-render?
@@ -23,7 +26,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
 - **Guardrailed** - AI can only use components in your catalog
 - **Guardrailed** - AI can only use components in your catalog
 - **Predictable** - JSON output matches your schema, every time
 - **Predictable** - JSON output matches your schema, every time
 - **Fast** - Stream and render progressively as the model responds
 - **Fast** - Stream and render progressively as the model responds
-- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
+- **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
 - **Batteries Included** - 36 pre-built shadcn/ui components ready to use
 - **Batteries Included** - 36 pre-built shadcn/ui components ready to use
 
 
 ## Quick Start
 ## Quick Start
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
 |---------|-------------|
 |---------|-------------|
 | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
 | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
 | `@json-render/react` | React renderer, contexts, hooks |
 | `@json-render/react` | React renderer, contexts, hooks |
+| `@json-render/vue` | Vue 3 renderer, composables, providers |
 | `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
 | `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
@@ -149,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
 <Renderer spec={spec} registry={registry} />
 <Renderer spec={spec} registry={registry} />
 ```
 ```
 
 
+### Vue (UI)
+
+```typescript
+import { h } from "vue";
+import { defineRegistry, Renderer } from "@json-render/vue";
+import { schema } from "@json-render/vue/schema";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) =>
+      h("div", { class: "card" }, [h("h3", null, props.title), children]),
+    Button: ({ props, emit }) =>
+      h("button", { onClick: () => emit("press") }, props.label),
+  },
+});
+
+// In your Vue component template:
+// <Renderer :spec="spec" :registry="registry" />
+```
+
 ### shadcn/ui (Web)
 ### shadcn/ui (Web)
 
 
 ```tsx
 ```tsx

+ 246 - 0
apps/web/app/(main)/docs/api/vue/page.mdx

@@ -0,0 +1,246 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/vue")
+
+# @json-render/vue
+
+Vue 3 components, providers, and composables.
+
+## Providers
+
+### StateProvider
+
+```vue
+<StateProvider :initial-state="object" :on-state-change="fn">
+  <!-- children -->
+</StateProvider>
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
+| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
+| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
+
+#### External Store (Controlled Mode)
+
+Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
+
+```typescript
+import { createStateStore, type StateStore } from "@json-render/vue";
+
+const store = createStateStore({ count: 0 });
+```
+
+```vue
+<StateProvider :store="store">
+  <!-- children -->
+</StateProvider>
+```
+
+```typescript
+// Mutate from anywhere — Vue re-renders automatically:
+store.set("/count", 1);
+```
+
+### ActionProvider
+
+```vue
+<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
+  <!-- children -->
+</ActionProvider>
+
+// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
+```
+
+### VisibilityProvider
+
+```vue
+<VisibilityProvider>
+  <!-- children -->
+</VisibilityProvider>
+```
+
+`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
+
+### ValidationProvider
+
+```vue
+<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
+  <!-- children -->
+</ValidationProvider>
+
+// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
+```
+
+## defineRegistry
+
+Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
+
+When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
+
+```typescript
+import { h } from "vue";
+import { defineRegistry } from "@json-render/vue";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) =>
+      h("div", { class: "card" }, [h("h3", null, props.title), children]),
+    Button: ({ props, emit }) =>
+      h("button", { onClick: () => emit("press") }, props.label),
+  },
+  // Required when catalog declares actions:
+  actions: {
+    submit: async (params) => { /* ... */ },
+  },
+});
+
+// Pass to <Renderer>
+// <Renderer :spec="spec" :registry="registry" />
+```
+
+## Components
+
+### Renderer
+
+```vue
+<Renderer
+  :spec="Spec"           // The UI spec to render
+  :registry="Registry"   // Component registry (from defineRegistry)
+  :loading="boolean"     // Optional loading state
+  :fallback="Component"  // Optional fallback for unknown types
+/>
+```
+
+### Component Props (via defineRegistry)
+
+```typescript
+import type { VNode } from "vue";
+
+interface ComponentContext<P> {
+  props: P;                          // Typed props from catalog
+  children?: VNode | VNode[];        // Rendered children (for container components)
+  emit: (event: string) => void;     // Emit a named event (always defined)
+  on: (event: string) => EventHandle; // Get event handle with metadata
+  loading?: boolean;
+  bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
+}
+
+interface EventHandle {
+  emit: () => void;              // Fire the event
+  shouldPreventDefault: boolean; // Whether any binding requested preventDefault
+  bound: boolean;                // Whether any handler is bound
+}
+```
+
+Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
+
+```typescript
+Link: ({ props, on }) => {
+  const click = on("click");
+  return h("a", {
+    href: props.href,
+    onClick: (e: MouseEvent) => {
+      if (click.shouldPreventDefault) e.preventDefault();
+      click.emit();
+    },
+  }, props.label);
+},
+```
+
+### BaseComponentProps
+
+Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
+
+```typescript
+import type { BaseComponentProps } from "@json-render/vue";
+
+const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
+  h("div", null, [props.title, children]);
+```
+
+## Composables
+
+### useStateStore
+
+```typescript
+const {
+  state,   // ShallowRef<StateModel> — access with state.value
+  get,     // (path: string) => unknown
+  set,     // (path: string, value: unknown) => void
+  update,  // (updates: Record<string, unknown>) => void
+} = useStateStore();
+```
+
+> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
+
+### useStateValue
+
+```typescript
+const value = useStateValue(path: string); // ComputedRef<T | undefined>
+```
+
+Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
+
+### useStateBinding (deprecated)
+
+> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
+
+```typescript
+const [value, setValue] = useStateBinding(path: string);
+// value: ComputedRef<T | undefined>
+// setValue: (value: T) => void
+```
+
+### useActions
+
+```typescript
+const { execute } = useActions();
+// execute(binding: ActionBinding) => Promise<void>
+```
+
+### useAction
+
+```typescript
+const { execute, isLoading } = useAction(binding: ActionBinding);
+// execute: () => Promise<void>
+// isLoading: ComputedRef<boolean>
+```
+
+### useIsVisible
+
+```typescript
+const isVisible = useIsVisible(condition?: VisibilityCondition);
+```
+
+### useFieldValidation
+
+```typescript
+const {
+  state,     // ComputedRef<FieldValidationState>
+  validate,  // () => ValidationResult
+  touch,     // () => void
+  clear,     // () => void
+  errors,    // ComputedRef<string[]>
+  isValid,   // ComputedRef<boolean>
+} = useFieldValidation(path: string, config?: ValidationConfig);
+```
+
+`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
+
+## Differences from `@json-render/react`
+
+| API | React | Vue | Note |
+|-----|-------|-----|------|
+| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
+| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
+| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
+| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
+| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
+| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
+| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
+| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
+| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
+| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
+| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
+| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |

+ 12 - 9
apps/web/app/(main)/docs/installation/page.mdx

@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
 
 
 <PackageInstall packages="@json-render/core @json-render/react" />
 <PackageInstall packages="@json-render/core @json-render/react" />
 
 
+Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
+
+<PackageInstall packages="react zod" />
+
+## For Vue
+
+<PackageInstall packages="@json-render/core @json-render/vue" />
+
+Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
+
+<PackageInstall packages="vue zod" />
+
 ## For React UI with shadcn/ui
 ## For React UI with shadcn/ui
 
 
 Pre-built components for fast prototyping and production use:
 Pre-built components for fast prototyping and production use:
@@ -37,15 +49,6 @@ If you want to wire json-render to an existing state management library instead
 
 
 See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
 See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
 
 
-## Peer Dependencies
-
-json-render requires the following peer dependencies:
-
-- `react` ^19.0.0
-- `zod` ^4.0.0
-
-<PackageInstall packages="react zod" />
-
 ## For AI Integration
 ## For AI Integration
 
 
 To use json-render with AI models, you'll also need the Vercel AI SDK:
 To use json-render with AI models, you'll also need the Vercel AI SDK:

+ 11 - 0
apps/web/lib/docs-navigation.ts

@@ -69,6 +69,16 @@ export const docsNavigation: NavSection[] = [
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
         external: true,
         external: true,
       },
       },
+      {
+        title: "Vue",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
+        external: true,
+      },
+      {
+        title: "Renders with Vite (Vue / React)",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
+        external: true,
+      },
     ],
     ],
   },
   },
   {
   {
@@ -97,6 +107,7 @@ export const docsNavigation: NavSection[] = [
       { title: "@json-render/shadcn", href: "/docs/api/shadcn" },
       { title: "@json-render/shadcn", href: "/docs/api/shadcn" },
       { title: "@json-render/react-native", href: "/docs/api/react-native" },
       { title: "@json-render/react-native", href: "/docs/api/react-native" },
       { title: "@json-render/remotion", href: "/docs/api/remotion" },
       { title: "@json-render/remotion", href: "/docs/api/remotion" },
+      { title: "@json-render/vue", href: "/docs/api/vue" },
       { title: "@json-render/codegen", href: "/docs/api/codegen" },
       { title: "@json-render/codegen", href: "/docs/api/codegen" },
     ],
     ],
   },
   },

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

@@ -38,6 +38,7 @@ export const PAGE_TITLES: Record<string, string> = {
   // API references
   // API references
   "docs/api/core": "@json-render/core API",
   "docs/api/core": "@json-render/core API",
   "docs/api/react": "@json-render/react API",
   "docs/api/react": "@json-render/react API",
+  "docs/api/vue": "@json-render/vue API",
   "docs/api/react-pdf": "@json-render/react-pdf API",
   "docs/api/react-pdf": "@json-render/react-pdf API",
   "docs/api/react-native": "@json-render/react-native API",
   "docs/api/react-native": "@json-render/react-native API",
   "docs/api/codegen": "@json-render/codegen API",
   "docs/api/codegen": "@json-render/codegen API",

+ 31 - 0
examples/vite-renderers/index.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>@json-render multi-renderer demo</title>
+    <style>
+      * {
+        box-sizing: border-box;
+        margin: 0;
+        padding: 0;
+      }
+
+      body {
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        background: #f3f4f6;
+        min-height: 100vh;
+      }
+
+      #renderer-root {
+        max-width: 640px;
+        margin: 0 auto;
+        padding: 24px;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="renderer-root"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 27 - 0
examples/vite-renderers/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "vite-renderers",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "portless vite-renderers.json-render vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@json-render/vue": "workspace:*",
+    "react": "^19.2.4",
+    "react-dom": "^19.2.4",
+    "vue": "^3.5.29",
+    "zod": "^4.3.6"
+  },
+  "devDependencies": {
+    "@types/react": "^19.2.14",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^5.1.4",
+    "@vitejs/plugin-vue": "^6.0.4",
+    "typescript": "^5.9.3",
+    "vite": "^7.3.1"
+  }
+}

+ 30 - 0
examples/vite-renderers/src/main.ts

@@ -0,0 +1,30 @@
+import "./shared/styles.css";
+import { demoSpec } from "./spec";
+
+type Renderer = "vue" | "react";
+
+const container = document.getElementById("renderer-root") as HTMLElement;
+
+let unmountCurrent: (() => void) | null = null;
+
+async function switchTo(renderer: Renderer) {
+  unmountCurrent?.();
+  container.innerHTML = "";
+  if (renderer === "vue") {
+    const mod = await import("./vue/mount.ts");
+    mod.mount(container, renderer, demoSpec);
+    unmountCurrent = mod.unmount;
+  } else {
+    const mod = await import("./react/mount.tsx");
+    mod.mount(container, renderer, demoSpec);
+    unmountCurrent = mod.unmount;
+  }
+}
+
+// The RendererTabs component (rendered by JSON renderer) dispatches this event
+document.addEventListener("switch-renderer", (e: Event) => {
+  switchTo((e as CustomEvent<string>).detail as Renderer);
+});
+
+// Default: Vue
+switchTo("vue");

+ 51 - 0
examples/vite-renderers/src/react/App.tsx

@@ -0,0 +1,51 @@
+import { useMemo } from "react";
+import type { Spec } from "@json-render/core";
+import {
+  StateProvider,
+  ActionProvider,
+  VisibilityProvider,
+  ValidationProvider,
+  Renderer,
+  defineRegistry,
+  useStateStore,
+} from "@json-render/react";
+import { catalog } from "./catalog";
+import { components } from "./registry";
+import { actionStubs, makeHandlers } from "../shared/handlers";
+
+const { registry } = defineRegistry(catalog, {
+  components,
+  actions: actionStubs,
+});
+
+function DemoRenderer({ spec }: { spec: Spec }) {
+  const { get, set } = useStateStore();
+  const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
+  return (
+    <ActionProvider handlers={handlers}>
+      <VisibilityProvider>
+        <ValidationProvider>
+          <Renderer spec={spec} registry={registry} />
+        </ValidationProvider>
+      </VisibilityProvider>
+    </ActionProvider>
+  );
+}
+
+export default function App({
+  initialRenderer = "vue",
+  spec,
+}: {
+  initialRenderer?: string;
+  spec: Spec;
+}) {
+  return (
+    <div className={`renderer-${initialRenderer}`}>
+      <StateProvider
+        initialState={{ ...spec.state, renderer: initialRenderer }}
+      >
+        <DemoRenderer spec={spec} />
+      </StateProvider>
+    </div>
+  );
+}

+ 5 - 0
examples/vite-renderers/src/react/catalog.ts

@@ -0,0 +1,5 @@
+import { schema } from "@json-render/react/schema";
+import { catalogDef } from "../shared/catalog-def";
+
+export const catalog = schema.createCatalog(catalogDef);
+export type AppCatalog = typeof catalog;

+ 15 - 0
examples/vite-renderers/src/react/mount.tsx

@@ -0,0 +1,15 @@
+import { createRoot, type Root } from "react-dom/client";
+import type { Spec } from "@json-render/core";
+import App from "./App";
+
+let root: Root | null = null;
+
+export function mount(container: HTMLElement, renderer: string, spec: Spec) {
+  root = createRoot(container);
+  root.render(<App initialRenderer={renderer} spec={spec} />);
+}
+
+export function unmount() {
+  root?.unmount();
+  root = null;
+}

+ 155 - 0
examples/vite-renderers/src/react/registry.tsx

@@ -0,0 +1,155 @@
+import type { Components } from "@json-render/react";
+import type { AppCatalog } from "./catalog";
+
+export const components: Components<AppCatalog> = {
+  Stack: ({ props, children }) => (
+    <div
+      className={[
+        "json-render-stack",
+        props.direction === "horizontal" && "json-render-stack--horizontal",
+        props.align && `json-render-stack--align-${props.align}`,
+      ]
+        .filter(Boolean)
+        .join(" ")}
+      style={{
+        gap: props.gap ? `${props.gap}px` : undefined,
+        padding: props.padding ? `${props.padding}px` : undefined,
+      }}
+    >
+      {children}
+    </div>
+  ),
+
+  Card: ({ props, children }) => (
+    <div className="json-render-card">
+      {props.title && (
+        <div className="json-render-card-title-wrap">
+          <h2 className="json-render-card-title">{props.title}</h2>
+        </div>
+      )}
+      {props.subtitle && (
+        <p className="json-render-card-subtitle">{props.subtitle}</p>
+      )}
+      {children}
+    </div>
+  ),
+
+  Text: ({ props }) => (
+    <span
+      className={[
+        "json-render-text",
+        props.size && props.size !== "md" && `json-render-text--${props.size}`,
+        props.weight &&
+          props.weight !== "normal" &&
+          `json-render-text--${props.weight}`,
+      ]
+        .filter(Boolean)
+        .join(" ")}
+      style={props.color ? { color: props.color } : undefined}
+    >
+      {String(props.content ?? "")}
+    </span>
+  ),
+
+  Button: ({ props, emit }) => (
+    <button
+      disabled={props.disabled}
+      onClick={() => emit("press")}
+      className={[
+        "json-render-button",
+        props.variant && `json-render-button--${props.variant}`,
+      ]
+        .filter(Boolean)
+        .join(" ")}
+    >
+      {props.label}
+    </button>
+  ),
+
+  Badge: ({ props }) => (
+    <span
+      className="json-render-badge"
+      style={
+        props.color
+          ? {
+              backgroundColor: `${props.color}20`,
+              color: props.color,
+              borderColor: `${props.color}40`,
+            }
+          : undefined
+      }
+    >
+      {props.label}
+    </span>
+  ),
+
+  ListItem: ({ props, emit }) => (
+    <div
+      onClick={() => emit("press")}
+      className={[
+        "json-render-list-item",
+        props.completed && "json-render-list-item--done",
+      ]
+        .filter(Boolean)
+        .join(" ")}
+    >
+      <div
+        className={[
+          "json-render-list-item-check",
+          props.completed && "json-render-list-item-check--done",
+        ]
+          .filter(Boolean)
+          .join(" ")}
+      >
+        {props.completed ? "✓" : ""}
+      </div>
+      <span
+        className={[
+          "json-render-list-item-text",
+          props.completed && "json-render-list-item-text--done",
+        ]
+          .filter(Boolean)
+          .join(" ")}
+      >
+        {props.title}
+      </span>
+    </div>
+  ),
+
+  RendererBadge: ({ props }) => (
+    <span className="json-render-renderer-badge">
+      <span className="json-render-renderer-dot" />
+      {props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
+    </span>
+  ),
+
+  RendererTabs: ({ props, emit }) => (
+    <div className="json-render-renderer-tabs-wrapper">
+      <span className="json-render-renderer-tabs-label">Render</span>
+      <div className="json-render-renderer-tabs">
+        <button
+          onClick={() => emit("pressVue")}
+          className={[
+            "json-render-renderer-tab",
+            props.renderer === "vue" && "json-render-renderer-tab--active",
+          ]
+            .filter(Boolean)
+            .join(" ")}
+        >
+          Vue
+        </button>
+        <button
+          onClick={() => emit("pressReact")}
+          className={[
+            "json-render-renderer-tab",
+            props.renderer === "react" && "json-render-renderer-tab--active",
+          ]
+            .filter(Boolean)
+            .join(" ")}
+        >
+          React
+        </button>
+      </div>
+    </div>
+  ),
+};

+ 99 - 0
examples/vite-renderers/src/shared/catalog-def.ts

@@ -0,0 +1,99 @@
+import { z } from "zod";
+
+/**
+ * Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
+ * Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
+ */
+export const catalogDef = {
+  components: {
+    Stack: {
+      props: z.object({
+        gap: z.number().optional(),
+        padding: z.number().optional(),
+        direction: z.enum(["vertical", "horizontal"]).optional(),
+        align: z.enum(["start", "center", "end"]).optional(),
+      }),
+      slots: ["default"],
+      description:
+        "Layout container that stacks children vertically or horizontally",
+    },
+    Card: {
+      props: z.object({
+        title: z.string().optional(),
+        subtitle: z.string().optional(),
+      }),
+      slots: ["default"],
+      description: "A card container with optional title and subtitle",
+    },
+    Text: {
+      props: z.object({
+        content: z.string(),
+        size: z.enum(["sm", "md", "lg", "xl"]).optional(),
+        weight: z.enum(["normal", "medium", "bold"]).optional(),
+        color: z.string().optional(),
+      }),
+      slots: [],
+      description: "Displays a text string",
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary", "danger"]).optional(),
+        disabled: z.boolean().optional(),
+      }),
+      slots: [],
+      description: "A clickable button that emits a 'press' event",
+    },
+    Badge: {
+      props: z.object({
+        label: z.string(),
+        color: z.string().optional(),
+      }),
+      slots: [],
+      description: "A small badge/tag label",
+    },
+    ListItem: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().optional(),
+        completed: z.boolean().optional(),
+      }),
+      slots: [],
+      description: "A single item in a list",
+    },
+    RendererTabs: {
+      props: z.object({ renderer: z.string() }),
+      slots: [],
+      description:
+        "Segmented tab control for switching between Vue and React renderers",
+    },
+    RendererBadge: {
+      props: z.object({ renderer: z.string() }),
+      slots: [],
+      description: "Badge indicating which renderer is currently active",
+    },
+  },
+  actions: {
+    increment: {
+      params: z.object({}),
+      description: "Increment the counter by 1",
+    },
+    decrement: {
+      params: z.object({}),
+      description: "Decrement the counter by 1",
+    },
+    reset: { params: z.object({}), description: "Reset the counter to 0" },
+    toggleItem: {
+      params: z.object({ index: z.number() }),
+      description: "Toggle the completed state of a todo item",
+    },
+    switchToVue: {
+      params: z.object({}),
+      description: "Switch to the Vue renderer",
+    },
+    switchToReact: {
+      params: z.object({}),
+      description: "Switch to the React renderer",
+    },
+  },
+};

+ 50 - 0
examples/vite-renderers/src/shared/handlers.ts

@@ -0,0 +1,50 @@
+type Get = (path: string) => unknown;
+type Set = (path: string, value: unknown) => void;
+
+/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
+export const actionStubs = {
+  increment: async () => {},
+  decrement: async () => {},
+  reset: async () => {},
+  toggleItem: async () => {},
+  switchToVue: async () => {},
+  switchToReact: async () => {},
+};
+
+/** Creates action handlers that close over the state store's get/set */
+export function makeHandlers(get: Get, set: Set) {
+  return {
+    increment: async () => {
+      set("/count", Number(get("/count") || 0) + 1);
+    },
+    decrement: async () => {
+      set("/count", Math.max(0, Number(get("/count") || 0) - 1));
+    },
+    reset: async () => {
+      set("/count", 0);
+    },
+    toggleItem: async (params: Record<string, unknown>) => {
+      const index = params.index as number;
+      const todos = (
+        get("/todos") as Array<{
+          id: number;
+          title: string;
+          completed: boolean;
+        }>
+      ).slice();
+      const item = todos[index];
+      if (item) todos[index] = { ...item, completed: !item.completed };
+      set("/todos", todos);
+    },
+    switchToVue: async () => {
+      document.dispatchEvent(
+        new CustomEvent("switch-renderer", { detail: "vue" }),
+      );
+    },
+    switchToReact: async () => {
+      document.dispatchEvent(
+        new CustomEvent("switch-renderer", { detail: "react" }),
+      );
+    },
+  };
+}

+ 248 - 0
examples/vite-renderers/src/shared/styles.css

@@ -0,0 +1,248 @@
+/* ---- Stack ---------------------------------------------------------------- */
+
+.json-render-stack {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.json-render-stack--horizontal {
+  flex-direction: row;
+  align-items: center;
+}
+
+.json-render-stack--align-start  { align-items: flex-start; }
+.json-render-stack--align-center { align-items: center; }
+.json-render-stack--align-end    { align-items: flex-end; }
+
+/* ---- Card ----------------------------------------------------------------- */
+
+.json-render-card {
+  background-color: white;
+  border-radius: 12px;
+  border: 1px solid #e5e7eb;
+  padding: 20px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.json-render-card-title-wrap {
+  margin-bottom: 4px;
+}
+
+.json-render-card-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #111827;
+  margin: 0;
+}
+
+.json-render-card-subtitle {
+  font-size: 13px;
+  color: #6b7280;
+  margin: 0 0 12px 0;
+}
+
+/* ---- Text ----------------------------------------------------------------- */
+
+.json-render-text {
+  font-size: 14px;
+  font-weight: 400;
+  color: #111827;
+}
+
+.json-render-text--sm { font-size: 12px; }
+.json-render-text--lg { font-size: 16px; }
+.json-render-text--xl { font-size: 24px; }
+
+.json-render-text--medium { font-weight: 500; }
+.json-render-text--bold   { font-weight: 700; }
+
+/* ---- Button --------------------------------------------------------------- */
+
+.json-render-button {
+  padding: 8px 16px;
+  border-radius: 8px;
+  border: none;
+  cursor: pointer;
+  font-weight: 500;
+  font-size: 14px;
+  transition: background 0.15s;
+  background-color: #3b82f6;
+  color: white;
+}
+
+.json-render-button:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+.json-render-button--primary {
+  background-color: #3b82f6;
+  color: white;
+}
+
+.json-render-button--secondary {
+  background-color: #f3f4f6;
+  color: #374151;
+}
+
+.json-render-button--danger {
+  background-color: #fee2e2;
+  color: #dc2626;
+}
+
+/* ---- Badge ---------------------------------------------------------------- */
+
+.json-render-badge {
+  display: inline-block;
+  padding: 4px 12px;
+  border-radius: 999px;
+  font-size: 13px;
+  font-weight: 500;
+  background-color: #e0f2fe;
+  color: #0369a1;
+  border: 1px solid #bae6fd;
+}
+
+/* ---- ListItem ------------------------------------------------------------- */
+
+.json-render-list-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  cursor: pointer;
+  background-color: #f9fafb;
+  border: 1px solid #e5e7eb;
+}
+
+.json-render-list-item--done {
+  background-color: #f0fdf4;
+  border-color: #bbf7d0;
+}
+
+.json-render-list-item-check {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  border: 2px solid #d1d5db;
+  background-color: transparent;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  color: white;
+}
+
+.json-render-list-item-check--done {
+  border-color: #16a34a;
+  background-color: #16a34a;
+}
+
+.json-render-list-item-text {
+  font-size: 14px;
+  color: #111827;
+  text-decoration: none;
+}
+
+.json-render-list-item-text--done {
+  color: #6b7280;
+  text-decoration: line-through;
+}
+
+/* ---- RendererBadge -------------------------------------------------------- */
+
+.json-render-renderer-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 10px 15px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 500;
+  /* default colors; overridden by renderer parent class below */
+  background-color: #e0f2fe;
+  color: #0369a1;
+  border: 1px solid #bae6fd;
+}
+
+.json-render-renderer-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  display: inline-block;
+  /* background overridden by renderer parent class below */
+  background-color: #0369a1;
+}
+
+/* ---- RendererTabs --------------------------------------------------------- */
+
+.json-render-renderer-tabs-wrapper {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
+}
+
+.json-render-renderer-tabs-label {
+  font-size: 13px;
+  color: #6b7280;
+  font-weight: 500;
+}
+
+.json-render-renderer-tabs {
+  display: inline-flex;
+  border-radius: 8px;
+  border: 1px solid #e5e7eb;
+  overflow: hidden;
+}
+
+.json-render-renderer-tab {
+  padding: 6px 16px;
+  border: none;
+  border-right: 0;
+  cursor: pointer;
+  font-size: 13px;
+  font-weight: 500;
+  background-color: white;
+  color: #374151;
+  transition: background 0.15s;
+}
+
+.json-render-renderer-tab:first-child {
+  border-right: 1px solid #e5e7eb;
+}
+
+/* ---- Renderer-specific overrides ----------------------------------------- */
+
+.renderer-vue .json-render-renderer-badge {
+  color: #42b883;
+  background-color: #42b88318;
+  border-color: #42b88340;
+}
+
+.renderer-vue .json-render-renderer-dot {
+  background-color: #42b883;
+}
+
+.renderer-vue .json-render-renderer-tab--active {
+  background-color: #42b883;
+  color: white;
+}
+
+.renderer-react .json-render-renderer-badge {
+  color: #149eca;
+  background-color: #149eca18;
+  border-color: #149eca40;
+}
+
+.renderer-react .json-render-renderer-dot {
+  background-color: #149eca;
+}
+
+.renderer-react .json-render-renderer-tab--active {
+  background-color: #149eca;
+  color: white;
+}

+ 126 - 0
examples/vite-renderers/src/spec.ts

@@ -0,0 +1,126 @@
+import type { Spec } from "@json-render/core";
+
+export const demoSpec: Spec = {
+  root: "root",
+  state: {
+    renderer: "vue",
+    count: 0,
+    todos: [
+      { id: 1, title: "Learn JSON Render", completed: true },
+      {
+        id: 2,
+        title: "Try @json-render/vue or @json-render/react",
+        completed: false,
+      },
+      { id: 3, title: "Build something awesome", completed: false },
+    ],
+  },
+  elements: {
+    root: {
+      type: "Stack",
+      props: { gap: 24, padding: 24, direction: "vertical" },
+      children: [
+        "demo-title",
+        "renderer-tabs",
+        "renderer-badge",
+        "counter-card",
+        "milestone-badge",
+        "todos-card",
+      ],
+    },
+
+    "demo-title": {
+      type: "Text",
+      props: {
+        content: "@json-render multi-renderer demo",
+        size: "xl",
+        weight: "bold",
+      },
+    },
+    "renderer-badge": {
+      type: "RendererBadge",
+      props: { renderer: { $state: "/renderer" } },
+    },
+    "renderer-tabs": {
+      type: "RendererTabs",
+      props: { renderer: { $state: "/renderer" } },
+      on: {
+        pressVue: { action: "switchToVue" },
+        pressReact: { action: "switchToReact" },
+      },
+    },
+
+    // ---- Counter card ----
+    "counter-card": {
+      type: "Card",
+      props: {
+        title: "Counter",
+        subtitle: "Click the buttons to change the count",
+      },
+      children: ["counter-body"],
+    },
+    "counter-body": {
+      type: "Stack",
+      props: { gap: 12, direction: "horizontal", align: "center" },
+      children: [
+        "decrement-btn",
+        "counter-value",
+        "increment-btn",
+        "reset-btn",
+      ],
+    },
+    "decrement-btn": {
+      type: "Button",
+      props: { label: "−", variant: "secondary" },
+      on: { press: { action: "decrement" } },
+    },
+    "counter-value": {
+      type: "Text",
+      props: {
+        content: { $state: "/count" },
+        size: "xl",
+        weight: "bold",
+      },
+    },
+    "increment-btn": {
+      type: "Button",
+      props: { label: "+", variant: "primary" },
+      on: { press: { action: "increment" } },
+    },
+    "reset-btn": {
+      type: "Button",
+      props: { label: "Reset", variant: "danger" },
+      on: { press: { action: "reset" } },
+    },
+
+    // ---- Milestone badge (visible only when count >= 10) ----
+    "milestone-badge": {
+      type: "Badge",
+      props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
+      visible: { $state: "/count", gte: 10 },
+    },
+
+    // ---- Todos card ----
+    "todos-card": {
+      type: "Card",
+      props: { title: "Todo List", subtitle: "Your tasks" },
+      children: ["todos-list"],
+    },
+    "todos-list": {
+      type: "Stack",
+      props: { gap: 8, direction: "vertical" },
+      repeat: { statePath: "/todos", key: "id" },
+      children: ["todo-item"],
+    },
+    "todo-item": {
+      type: "ListItem",
+      props: {
+        title: { $item: "title" },
+        completed: { $item: "completed" },
+      },
+      on: {
+        press: { action: "toggleItem", params: { index: { $index: true } } },
+      },
+    },
+  },
+};

+ 19 - 0
examples/vite-renderers/src/vue/App.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+import type { Spec } from "@json-render/core";
+import { StateProvider } from "@json-render/vue";
+import DemoRenderer from "./DemoRenderer.vue";
+
+const props = defineProps<{
+  initialRenderer?: string; spec: Spec }>();
+const initialState = {
+  ...props.spec.state,
+  renderer: props.initialRenderer ?? "vue" };
+</script>
+
+<template>
+  <div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
+    <StateProvider :initial-state="initialState">
+      <DemoRenderer :spec="props.spec" />
+    </StateProvider>
+  </div>
+</template>

+ 25 - 0
examples/vite-renderers/src/vue/DemoRenderer.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+import type { Spec } from "@json-render/core";
+import {
+  ActionProvider, ValidationProvider, VisibilityProvider,
+  Renderer, defineRegistry, useStateStore,
+} from "@json-render/vue";
+import { catalog } from "./catalog";
+import { components } from "./registry";
+import { actionStubs, makeHandlers } from "../shared/handlers";
+
+const props = defineProps<{ spec: Spec }>();
+const { get, set } = useStateStore();
+const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
+const handlers = makeHandlers(get, set);
+</script>
+
+<template>
+  <ActionProvider :handlers="handlers">
+    <VisibilityProvider>
+      <ValidationProvider>
+        <Renderer :spec="props.spec" :registry="registry" />
+      </ValidationProvider>
+    </VisibilityProvider>
+  </ActionProvider>
+</template>

+ 5 - 0
examples/vite-renderers/src/vue/catalog.ts

@@ -0,0 +1,5 @@
+import { schema } from "@json-render/vue/schema";
+import { catalogDef } from "../shared/catalog-def";
+
+export const catalog = schema.createCatalog(catalogDef);
+export type AppCatalog = typeof catalog;

+ 15 - 0
examples/vite-renderers/src/vue/mount.ts

@@ -0,0 +1,15 @@
+import { createApp, type App } from "vue";
+import type { Spec } from "@json-render/core";
+import VueApp from "./App.vue";
+
+let app: App | null = null;
+
+export function mount(container: HTMLElement, renderer: string, spec: Spec) {
+  app = createApp(VueApp, { initialRenderer: renderer, spec });
+  app.mount(container);
+}
+
+export function unmount() {
+  app?.unmount();
+  app = null;
+}

+ 166 - 0
examples/vite-renderers/src/vue/registry.ts

@@ -0,0 +1,166 @@
+import { h } from "vue";
+import type { Components } from "@json-render/vue";
+import type { AppCatalog } from "./catalog";
+
+export const components: Components<AppCatalog> = {
+  Stack: ({ props, children }) =>
+    h(
+      "div",
+      {
+        class: [
+          "json-render-stack",
+          props.direction === "horizontal" && "json-render-stack--horizontal",
+          props.align && `json-render-stack--align-${props.align}`,
+        ]
+          .filter(Boolean)
+          .join(" "),
+        style: {
+          gap: props.gap ? `${props.gap}px` : undefined,
+          padding: props.padding ? `${props.padding}px` : undefined,
+        },
+      },
+      children,
+    ),
+
+  Card: ({ props, children }) =>
+    h("div", { class: "json-render-card" }, [
+      props.title &&
+        h("div", { class: "json-render-card-title-wrap" }, [
+          h("h2", { class: "json-render-card-title" }, props.title),
+        ]),
+      props.subtitle &&
+        h("p", { class: "json-render-card-subtitle" }, props.subtitle),
+      children,
+    ]),
+
+  Text: ({ props }) =>
+    h(
+      "span",
+      {
+        class: [
+          "json-render-text",
+          props.size &&
+            props.size !== "md" &&
+            `json-render-text--${props.size}`,
+          props.weight &&
+            props.weight !== "normal" &&
+            `json-render-text--${props.weight}`,
+        ]
+          .filter(Boolean)
+          .join(" "),
+        style: props.color ? { color: props.color } : undefined,
+      },
+      String(props.content ?? ""),
+    ),
+
+  Button: ({ props, emit }) =>
+    h(
+      "button",
+      {
+        disabled: props.disabled,
+        onClick: () => emit("press"),
+        class: [
+          "json-render-button",
+          props.variant && `json-render-button--${props.variant}`,
+        ]
+          .filter(Boolean)
+          .join(" "),
+      },
+      props.label,
+    ),
+
+  Badge: ({ props }) =>
+    h(
+      "span",
+      {
+        class: "json-render-badge",
+        style: props.color
+          ? {
+              backgroundColor: `${props.color}20`,
+              color: props.color,
+              borderColor: `${props.color}40`,
+            }
+          : undefined,
+      },
+      props.label,
+    ),
+
+  ListItem: ({ props, emit }) =>
+    h(
+      "div",
+      {
+        onClick: () => emit("press"),
+        class: [
+          "json-render-list-item",
+          props.completed && "json-render-list-item--done",
+        ]
+          .filter(Boolean)
+          .join(" "),
+      },
+      [
+        h(
+          "div",
+          {
+            class: [
+              "json-render-list-item-check",
+              props.completed && "json-render-list-item-check--done",
+            ]
+              .filter(Boolean)
+              .join(" "),
+          },
+          props.completed ? "✓" : "",
+        ),
+        h(
+          "span",
+          {
+            class: [
+              "json-render-list-item-text",
+              props.completed && "json-render-list-item-text--done",
+            ]
+              .filter(Boolean)
+              .join(" "),
+          },
+          props.title,
+        ),
+      ],
+    ),
+
+  RendererBadge: ({ props }) =>
+    h("span", { class: "json-render-renderer-badge" }, [
+      h("span", { class: "json-render-renderer-dot" }),
+      props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
+    ]),
+
+  RendererTabs: ({ props, emit }) =>
+    h("div", { class: "json-render-renderer-tabs-wrapper" }, [
+      h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
+      h("div", { class: "json-render-renderer-tabs" }, [
+        h(
+          "button",
+          {
+            onClick: () => emit("pressVue"),
+            class: [
+              "json-render-renderer-tab",
+              props.renderer === "vue" && "json-render-renderer-tab--active",
+            ]
+              .filter(Boolean)
+              .join(" "),
+          },
+          "Vue",
+        ),
+        h(
+          "button",
+          {
+            onClick: () => emit("pressReact"),
+            class: [
+              "json-render-renderer-tab",
+              props.renderer === "react" && "json-render-renderer-tab--active",
+            ]
+              .filter(Boolean)
+              .join(" "),
+          },
+          "React",
+        ),
+      ]),
+    ]),
+};

+ 17 - 0
examples/vite-renderers/tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 7 - 0
examples/vite-renderers/vite.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+  plugins: [vue(), react({ include: /\.tsx$/ })],
+});

+ 26 - 0
examples/vue/index.html

@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>json-render vue example</title>
+    <style>
+      * {
+        box-sizing: border-box;
+        margin: 0;
+        padding: 0;
+      }
+      body {
+        font-family:
+          -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+        background: #f9fafb;
+        color: #111827;
+        min-height: 100vh;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 22 - 0
examples/vue/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "vue",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "portless vue.json-render vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/vue": "workspace:*",
+    "vue": "^3.5.0",
+    "zod": "^4.3.6"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.4",
+    "typescript": "^5.9.3",
+    "vite": "^7.3.1",
+    "vue-tsc": "^3.2.5"
+  }
+}

+ 14 - 0
examples/vue/src/App.vue

@@ -0,0 +1,14 @@
+<script setup lang="ts">
+import { StateProvider } from "@json-render/vue";
+import { demoSpec } from "./lib/spec";
+import DemoRenderer from "./DemoRenderer.vue";
+
+const initialState = demoSpec.state ?? {};
+</script>
+
+<template>
+  <!-- StateProvider sets up the reactive state store for the whole tree -->
+  <StateProvider :initial-state="initialState">
+    <DemoRenderer />
+  </StateProvider>
+</template>

+ 68 - 0
examples/vue/src/DemoRenderer.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import {
+  ActionProvider,
+  ValidationProvider,
+  VisibilityProvider,
+  Renderer,
+  defineRegistry,
+  useStateStore,
+} from "@json-render/vue";
+import { catalog } from "./lib/catalog";
+import { components } from "./lib/registry";
+import { demoSpec } from "./lib/spec";
+
+// Access the state store provided by the parent StateProvider
+const { get, set } = useStateStore();
+
+// Build registry — include stub actions to satisfy catalog types.
+// The actual logic runs in the handlers below, which have direct
+// access to the state store.
+const { registry } = defineRegistry(catalog, {
+  components,
+  actions: {
+    increment: async () => {},
+    decrement: async () => {},
+    reset: async () => {},
+    toggleItem: async () => {},
+  },
+});
+
+// Action handlers — close over the state store's get/set so they
+// can read and write state directly without needing an external store.
+const handlers = {
+  increment: async () => {
+    set("/count", Number(get("/count") || 0) + 1);
+  },
+  decrement: async () => {
+    set("/count", Math.max(0, Number(get("/count") || 0) - 1));
+  },
+  reset: async () => {
+    set("/count", 0);
+  },
+  toggleItem: async (params: Record<string, unknown>) => {
+    const index = params.index as number;
+    const todos = (
+      get("/todos") as Array<{
+        id: number;
+        title: string;
+        completed: boolean;
+      }>
+    ).slice();
+    const item = todos[index];
+    if (item) {
+      todos[index] = { ...item, completed: !item.completed };
+    }
+    set("/todos", todos);
+  },
+};
+</script>
+
+<template>
+  <ActionProvider :handlers="handlers">
+    <VisibilityProvider>
+      <ValidationProvider>
+        <Renderer :spec="demoSpec" :registry="registry" />
+      </ValidationProvider>
+    </VisibilityProvider>
+  </ActionProvider>
+</template>

+ 92 - 0
examples/vue/src/lib/catalog.ts

@@ -0,0 +1,92 @@
+import { schema } from "@json-render/vue/schema";
+import { z } from "zod";
+
+export const catalog = schema.createCatalog({
+  components: {
+    Stack: {
+      props: z.object({
+        gap: z.number().optional(),
+        padding: z.number().optional(),
+        direction: z.enum(["vertical", "horizontal"]).optional(),
+        align: z.enum(["start", "center", "end"]).optional(),
+      }),
+      slots: ["default"],
+      description:
+        "Layout container that stacks children vertically or horizontally",
+    },
+    Card: {
+      props: z.object({
+        title: z.string().optional(),
+        subtitle: z.string().optional(),
+      }),
+      slots: ["default"],
+      description: "A card container with optional title and subtitle",
+    },
+    Text: {
+      props: z.object({
+        content: z.string(),
+        size: z.enum(["sm", "md", "lg", "xl"]).optional(),
+        weight: z.enum(["normal", "medium", "bold"]).optional(),
+        color: z.string().optional(),
+      }),
+      slots: [],
+      description: "Displays a text string",
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary", "danger"]).optional(),
+        disabled: z.boolean().optional(),
+      }),
+      slots: [],
+      description: "A clickable button that emits a 'press' event",
+    },
+    Badge: {
+      props: z.object({
+        label: z.string(),
+        color: z.string().optional(),
+      }),
+      slots: [],
+      description: "A small badge/tag label",
+    },
+    ListItem: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().optional(),
+        completed: z.boolean().optional(),
+      }),
+      slots: [],
+      description: "A single item in a list",
+    },
+    Input: {
+      props: z.object({
+        value: z.string().optional(),
+        placeholder: z.string().optional(),
+      }),
+      slots: [],
+      description: "A text input field that supports two-way state binding",
+    },
+  },
+  actions: {
+    increment: {
+      params: z.object({}),
+      description: "Increment the counter by 1",
+    },
+    decrement: {
+      params: z.object({}),
+      description: "Decrement the counter by 1",
+    },
+    reset: {
+      params: z.object({}),
+      description: "Reset the counter to 0",
+    },
+    toggleItem: {
+      params: z.object({
+        index: z.number(),
+      }),
+      description: "Toggle the completed state of a todo item",
+    },
+  },
+});
+
+export type AppCatalog = typeof catalog;

+ 213 - 0
examples/vue/src/lib/registry.ts

@@ -0,0 +1,213 @@
+import { h } from "vue";
+import type { Components } from "@json-render/vue";
+import { useBoundProp } from "@json-render/vue";
+import type { AppCatalog } from "./catalog";
+
+export const components: Components<AppCatalog> = {
+  Stack: ({ props, children }) => {
+    const isHorizontal = props.direction === "horizontal";
+    return h(
+      "div",
+      {
+        style: {
+          display: "flex",
+          flexDirection: isHorizontal ? "row" : "column",
+          gap: props.gap ? `${props.gap}px` : undefined,
+          padding: props.padding ? `${props.padding}px` : undefined,
+          alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
+        },
+      },
+      children,
+    );
+  },
+
+  Card: ({ props, children }) =>
+    h(
+      "div",
+      {
+        style: {
+          backgroundColor: "white",
+          borderRadius: "12px",
+          border: "1px solid #e5e7eb",
+          padding: "20px",
+          boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
+        },
+      },
+      [
+        props.title &&
+          h("div", { style: { marginBottom: "4px" } }, [
+            h(
+              "h2",
+              {
+                style: {
+                  fontSize: "16px",
+                  fontWeight: "600",
+                  color: "#111827",
+                  margin: 0,
+                },
+              },
+              props.title,
+            ),
+          ]),
+        props.subtitle &&
+          h(
+            "p",
+            {
+              style: {
+                fontSize: "13px",
+                color: "#6b7280",
+                margin: "0 0 12px 0",
+              },
+            },
+            props.subtitle,
+          ),
+        children,
+      ],
+    ),
+
+  Text: ({ props }) => {
+    const sizeMap: Record<string, string> = {
+      sm: "12px",
+      md: "14px",
+      lg: "16px",
+      xl: "24px",
+    };
+    const weightMap: Record<string, string> = {
+      normal: "400",
+      medium: "500",
+      bold: "700",
+    };
+    return h(
+      "span",
+      {
+        style: {
+          fontSize: sizeMap[props.size ?? "md"] ?? "14px",
+          fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
+          color: props.color ?? "#111827",
+        },
+      },
+      String(props.content ?? ""),
+    );
+  },
+
+  Button: ({ props, emit }) =>
+    h(
+      "button",
+      {
+        disabled: props.disabled,
+        onClick: () => emit("press"),
+        style: {
+          padding: "8px 16px",
+          borderRadius: "8px",
+          border: "none",
+          cursor: props.disabled ? "not-allowed" : "pointer",
+          fontWeight: "500",
+          fontSize: "14px",
+          transition: "background 0.15s",
+          opacity: props.disabled ? "0.5" : "1",
+          backgroundColor:
+            props.variant === "danger"
+              ? "#fee2e2"
+              : props.variant === "secondary"
+                ? "#f3f4f6"
+                : "#3b82f6",
+          color:
+            props.variant === "danger"
+              ? "#dc2626"
+              : props.variant === "secondary"
+                ? "#374151"
+                : "white",
+        },
+      },
+      props.label,
+    ),
+
+  Badge: ({ props }) =>
+    h(
+      "span",
+      {
+        style: {
+          display: "inline-block",
+          padding: "4px 12px",
+          borderRadius: "999px",
+          fontSize: "13px",
+          fontWeight: "500",
+          backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
+          color: props.color ?? "#0369a1",
+          border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
+        },
+      },
+      props.label,
+    ),
+
+  Input: ({ props, bindings }) => {
+    const [value, setValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    return h("input", {
+      value: value ?? "",
+      placeholder: props.placeholder as string | undefined,
+      onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
+      style: {
+        padding: "8px 12px",
+        borderRadius: "8px",
+        border: "1px solid #d1d5db",
+        fontSize: "14px",
+        outline: "none",
+        width: "100%",
+        boxSizing: "border-box",
+      },
+    });
+  },
+
+  ListItem: ({ props, emit }) =>
+    h(
+      "div",
+      {
+        onClick: () => emit("press"),
+        style: {
+          display: "flex",
+          alignItems: "center",
+          gap: "12px",
+          padding: "10px 12px",
+          borderRadius: "8px",
+          cursor: "pointer",
+          backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
+          border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
+        },
+      },
+      [
+        h(
+          "div",
+          {
+            style: {
+              width: "18px",
+              height: "18px",
+              borderRadius: "50%",
+              border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
+              backgroundColor: props.completed ? "#16a34a" : "transparent",
+              flexShrink: "0",
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+              fontSize: "11px",
+              color: "white",
+            },
+          },
+          props.completed ? "✓" : "",
+        ),
+        h(
+          "span",
+          {
+            style: {
+              fontSize: "14px",
+              color: props.completed ? "#6b7280" : "#111827",
+              textDecoration: props.completed ? "line-through" : "none",
+            },
+          },
+          props.title,
+        ),
+      ],
+    ),
+};

+ 138 - 0
examples/vue/src/lib/spec.ts

@@ -0,0 +1,138 @@
+import type { Spec } from "@json-render/vue";
+
+export const demoSpec: Spec = {
+  root: "root",
+  state: {
+    count: 0,
+    name: "",
+    todos: [
+      { id: 1, title: "Learn Vue 3", completed: true },
+      { id: 2, title: "Try @json-render/vue", completed: false },
+      { id: 3, title: "Build something awesome", completed: false },
+    ],
+  },
+  elements: {
+    root: {
+      type: "Stack",
+      props: { gap: 24, padding: 24, direction: "vertical" },
+      children: [
+        "header",
+        "counter-card",
+        "milestone-badge",
+        "todos-card",
+        "input-card",
+      ],
+    },
+    header: {
+      type: "Text",
+      props: {
+        content: "@json-render/vue demo",
+        size: "xl",
+        weight: "bold",
+      },
+    },
+
+    // ---- Counter card ----
+    "counter-card": {
+      type: "Card",
+      props: {
+        title: "Counter",
+        subtitle: "Click the buttons to change the count",
+      },
+      children: ["counter-body"],
+    },
+    "counter-body": {
+      type: "Stack",
+      props: { gap: 12, direction: "horizontal", align: "center" },
+      children: [
+        "decrement-btn",
+        "counter-value",
+        "increment-btn",
+        "reset-btn",
+      ],
+    },
+    "decrement-btn": {
+      type: "Button",
+      props: { label: "−", variant: "secondary" },
+      on: { press: { action: "decrement" } },
+    },
+    "counter-value": {
+      type: "Text",
+      props: {
+        content: { $state: "/count" },
+        size: "xl",
+        weight: "bold",
+      },
+    },
+    "increment-btn": {
+      type: "Button",
+      props: { label: "+", variant: "primary" },
+      on: { press: { action: "increment" } },
+    },
+    "reset-btn": {
+      type: "Button",
+      props: { label: "Reset", variant: "danger" },
+      on: { press: { action: "reset" } },
+    },
+
+    // ---- Milestone badge (visible only when count >= 10) ----
+    "milestone-badge": {
+      type: "Badge",
+      props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
+      visible: { $state: "/count", gte: 10 },
+    },
+
+    // ---- Todos card ----
+    "todos-card": {
+      type: "Card",
+      props: { title: "Todo List", subtitle: "Your tasks" },
+      children: ["todos-list"],
+    },
+    "todos-list": {
+      type: "Stack",
+      props: { gap: 8, direction: "vertical" },
+      repeat: { statePath: "/todos", key: "id" },
+      children: ["todo-item"],
+    },
+    "todo-item": {
+      type: "ListItem",
+      props: {
+        title: { $item: "title" },
+        completed: { $item: "completed" },
+      },
+      on: {
+        press: { action: "toggleItem", params: { index: { $index: true } } },
+      },
+    },
+
+    // ---- Bound Input card (useBoundProp demo) ----
+    "input-card": {
+      type: "Card",
+      props: {
+        title: "Bound Input",
+        subtitle: "Type to update state — the display text reacts in real time",
+      },
+      children: ["input-body"],
+    },
+    "input-body": {
+      type: "Stack",
+      props: { gap: 12, direction: "vertical" },
+      children: ["name-input", "name-display"],
+    },
+    "name-input": {
+      type: "Input",
+      props: {
+        value: { $bindState: "/name" },
+        placeholder: "Enter your name…",
+      },
+    },
+    "name-display": {
+      type: "Text",
+      props: {
+        content: { $state: "/name" },
+        size: "md",
+        color: "#6b7280",
+      },
+    },
+  },
+};

+ 4 - 0
examples/vue/src/main.ts

@@ -0,0 +1,4 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+
+createApp(App).mount("#app");

+ 17 - 0
examples/vue/tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "preserve",
+    "strict": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 6 - 0
examples/vue/vite.config.ts

@@ -0,0 +1,6 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+
+export default defineConfig({
+  plugins: [vue()],
+});

+ 496 - 0
packages/vue/README.md

@@ -0,0 +1,496 @@
+# @json-render/vue
+
+Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
+
+## Installation
+
+```bash
+npm install @json-render/vue @json-render/core zod
+```
+
+Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
+
+## Quick Start
+
+### 1. Create a Catalog
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/vue/schema";
+import { z } from "zod";
+
+export const catalog = defineCatalog(schema, {
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      description: "A card container",
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        action: z.string(),
+      }),
+      description: "A clickable button",
+    },
+    Input: {
+      props: z.object({
+        value: z.union([z.string(), z.record(z.unknown())]).nullable(),
+        label: z.string(),
+        placeholder: z.string().nullable(),
+      }),
+      description: "Text input field with optional value binding",
+    },
+  },
+  actions: {
+    submit: { description: "Submit the form" },
+    cancel: { description: "Cancel and close" },
+  },
+});
+```
+
+### 2. Define Component Implementations
+
+Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
+
+`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
+
+```typescript
+import { h } from "vue";
+import { defineRegistry } from "@json-render/vue";
+import { catalog } from "./catalog";
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) =>
+      h("div", { class: "card" }, [
+        h("h3", null, props.title),
+        props.description ? h("p", null, props.description) : null,
+        children,
+      ]),
+    Button: ({ props, emit }) =>
+      h("button", { onClick: () => emit("press") }, props.label),
+    Input: ({ props, bindings }) => {
+      // Use bindings?.value with a watcher to implement two-way binding
+      return h("label", null, [
+        props.label,
+        h("input", {
+          placeholder: props.placeholder ?? "",
+          value: props.value ?? "",
+        }),
+      ]);
+    },
+  },
+  // actions stubs are required when the catalog declares actions:
+  actions: {
+    submit: async () => {},
+    cancel: async () => {},
+  },
+});
+```
+
+> **Note:** Vue does not have `useBoundProp`. For two-way binding, use `{ "$bindState": "/path" }` on the value prop and handle the `bindings` object in your component.
+
+### 3. Render Specs
+
+```vue
+<script setup lang="ts">
+import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
+import { registry } from "./registry";
+
+const spec = { /* ... */ };
+
+function handleSubmit(params) {
+  console.log("Submit", params);
+}
+</script>
+
+<template>
+  <StateProvider :initial-state="{ form: { name: '' } }">
+    <ActionProvider :handlers="{ submit: handleSubmit }">
+      <Renderer :spec="spec" :registry="registry" />
+    </ActionProvider>
+  </StateProvider>
+</template>
+```
+
+## Spec Format
+
+The Vue renderer uses the same flat element map format as the React renderer:
+
+```typescript
+interface Spec {
+  root: string;                          // Key of the root element
+  elements: Record<string, UIElement>;   // Flat map of elements by key
+  state?: Record<string, unknown>;       // Optional initial state
+}
+
+interface UIElement {
+  type: string;                          // Component name from catalog
+  props: Record<string, unknown>;        // Component props
+  children?: string[];                   // Keys of child elements
+  visible?: VisibilityCondition;         // Visibility condition
+}
+```
+
+Example spec:
+
+```json
+{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "type": "Card",
+      "props": { "title": "Welcome" },
+      "children": ["input-1", "btn-1"]
+    },
+    "input-1": {
+      "type": "Input",
+      "props": {
+        "value": { "$bindState": "/form/name" },
+        "label": "Name",
+        "placeholder": "Enter name"
+      }
+    },
+    "btn-1": {
+      "type": "Button",
+      "props": { "label": "Submit" },
+      "children": []
+    }
+  }
+}
+```
+
+## Providers
+
+### StateProvider
+
+Share data across components with JSON Pointer paths:
+
+```vue
+<StateProvider :initial-state="{ user: { name: 'John' } }">
+  <!-- children -->
+</StateProvider>
+```
+
+```typescript
+// In composables:
+const { state, get, set } = useStateStore();
+const name = get("/user/name");  // "John"
+set("/user/age", 25);
+
+// state is a ShallowRef<StateModel> — access with state.value
+console.log(state.value);
+```
+
+#### External Store (Controlled Mode)
+
+Pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):
+
+```typescript
+import { createStateStore, type StateStore } from "@json-render/vue";
+
+// Option 1: Use the built-in store outside of Vue
+const store = createStateStore({ count: 0 });
+```
+
+```vue
+<StateProvider :store="store">
+  <!-- children -->
+</StateProvider>
+```
+
+```typescript
+// Mutate from anywhere — Vue will re-render automatically:
+store.set("/count", 1);
+
+// Option 2: Implement the StateStore interface with your own backend
+const piniaStore: StateStore = {
+  get: (path) => getByPath(myStore.$state, path),
+  set: (path, value) => myStore.$patch(/* ... */),
+  update: (updates) => myStore.$patch(/* ... */),
+  getSnapshot: () => myStore.$state,
+  subscribe: (listener) => myStore.$subscribe(listener),
+};
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored.
+
+### ActionProvider
+
+Handle actions from components:
+
+```vue
+<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
+  <!-- children -->
+</ActionProvider>
+```
+
+### VisibilityProvider
+
+Control element visibility based on data:
+
+```vue
+<VisibilityProvider>
+  <!-- children -->
+</VisibilityProvider>
+```
+
+```json
+{
+  "type": "Alert",
+  "props": { "message": "Error!" },
+  "visible": { "$state": "/form/hasError" }
+}
+```
+
+### ValidationProvider
+
+Add field validation:
+
+```vue
+<ValidationProvider>
+  <!-- children -->
+</ValidationProvider>
+```
+
+```typescript
+// Use validation composable:
+const { errors, validate } = useFieldValidation("/form/email", {
+  checks: [
+    { type: "required", message: "Email required" },
+    { type: "email", message: "Invalid email" },
+  ],
+});
+```
+
+## Composables
+
+| Composable | Purpose |
+|------------|---------|
+| `useStateStore()` | Access state context (`state` as `ShallowRef`, `get`, `set`, `update`) |
+| `useStateValue(path)` | Get single value from state |
+| `useStateBinding(path)` | Two-way data binding (deprecated — use `$bindState` instead) |
+| `useIsVisible(condition)` | Check if a visibility condition is met |
+| `useActions()` | Access action context |
+| `useAction(binding)` | Get a single action dispatch function |
+| `useFieldValidation(path, config)` | Field validation state |
+
+> **Note:** `useStateStore().state` returns a `ShallowRef<StateModel>` — use `state.value` to access the underlying object. This differs from the React renderer where `state` is a plain object.
+
+## Visibility Conditions
+
+```typescript
+// Truthiness check
+{ "$state": "/user/isAdmin" }
+
+// Auth state (use state path)
+{ "$state": "/auth/isSignedIn" }
+
+// Comparisons (flat style)
+{ "$state": "/status", "eq": "active" }
+{ "$state": "/count", "gt": 10 }
+
+// Negation
+{ "$state": "/maintenance", "not": true }
+
+// Multiple conditions (implicit AND)
+[
+  { "$state": "/feature/enabled" },
+  { "$state": "/maintenance", "not": true }
+]
+
+// Always / never
+true   // always visible
+false  // never visible
+```
+
+TypeScript helpers from `@json-render/core`:
+
+```typescript
+import { visibility } from "@json-render/core";
+
+visibility.when("/path")       // { $state: "/path" }
+visibility.unless("/path")     // { $state: "/path", not: true }
+visibility.eq("/path", val)    // { $state: "/path", eq: val }
+visibility.neq("/path", val)   // { $state: "/path", neq: val }
+visibility.and(cond1, cond2)  // { $and: [cond1, cond2] }
+visibility.always             // true
+visibility.never              // false
+```
+
+## Dynamic Prop Expressions
+
+Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.
+
+```json
+{
+  "type": "Badge",
+  "props": {
+    "label": { "$state": "/user/role" },
+    "color": {
+      "$cond": { "$state": "/user/role", "eq": "admin" },
+      "$then": "red",
+      "$else": "gray"
+    }
+  }
+}
+```
+
+For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop. Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop.
+
+See [@json-render/core](../core/README.md) for full expression syntax.
+
+## Built-in Actions
+
+The `setState`, `pushState`, and `removeState` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
+
+```json
+{
+  "type": "Button",
+  "props": { "label": "Switch Tab" },
+  "on": {
+    "press": {
+      "action": "setState",
+      "params": { "statePath": "/activeTab", "value": "settings" }
+    }
+  },
+  "children": []
+}
+```
+
+## Component Props
+
+When using `defineRegistry`, components receive these props via their render function:
+
+```typescript
+import type { VNode } from "vue";
+
+interface ComponentContext<P> {
+  props: P;                          // Typed props from the catalog (expressions resolved)
+  children?: VNode | VNode[];        // Rendered children (for container components)
+  emit: (event: string) => void;     // Emit a named event (always defined)
+  on: (event: string) => EventHandle; // Get event handle with metadata
+  loading?: boolean;                 // Whether the parent is loading
+  bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions
+}
+
+interface EventHandle {
+  emit: () => void;            // Fire the event
+  shouldPreventDefault: boolean; // Whether any binding requested preventDefault
+  bound: boolean;              // Whether any handler is bound
+}
+```
+
+Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`:
+
+```typescript
+Link: ({ props, on }) => {
+  const click = on("click");
+  return h("a", {
+    href: props.href,
+    onClick: (e: MouseEvent) => {
+      if (click.shouldPreventDefault) e.preventDefault();
+      click.emit();
+    },
+  }, props.label);
+},
+```
+
+### `BaseComponentProps`
+
+For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic `BaseComponentProps` type:
+
+```typescript
+import type { BaseComponentProps } from "@json-render/vue";
+
+const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
+  h("div", null, [props.title, children]);
+```
+
+## Generate AI Prompts
+
+```typescript
+const systemPrompt = catalog.prompt();
+// Returns detailed prompt with component/action descriptions
+```
+
+## Full Example
+
+```typescript
+import { h } from "vue";
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/vue/schema";
+import { defineRegistry, Renderer, StateProvider } from "@json-render/vue";
+import { z } from "zod";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    Greeting: {
+      props: z.object({ name: z.string() }),
+      description: "Displays a greeting",
+    },
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
+  },
+});
+
+const spec = {
+  root: "greeting-1",
+  elements: {
+    "greeting-1": {
+      type: "Greeting",
+      props: { name: "World" },
+      children: [],
+    },
+  },
+};
+
+// In your App.vue:
+// <StateProvider>
+//   <Renderer :spec="spec" :registry="registry" />
+// </StateProvider>
+```
+
+## Key Exports
+
+| Export | Purpose |
+|--------|---------|
+| `defineRegistry` | Create a type-safe component registry from a catalog |
+| `Renderer` | Render a spec using a registry |
+| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
+| `useStateStore` | Access state context (`state` is `ShallowRef<StateModel>`) |
+| `useStateValue` | Get single value from state |
+| `useActions` | Access actions context |
+| `useAction` | Get a single action dispatch function |
+| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
+
+### Types
+
+| Export | Purpose |
+|--------|---------|
+| `ComponentContext` | Typed component render function context (catalog-aware) |
+| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
+| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
+| `ActionProviderProps` | Props for `ActionProvider` |
+| `ValidationProviderProps` | Props for `ValidationProvider` |
+| `ComponentFn` | Component render function type |
+| `SetState` | State setter type |
+| `StateModel` | State model type |
+| `StateStore` | Interface for plugging in external state management |
+
+## Differences from `@json-render/react`
+
+| API | React | Vue | Note |
+|-----|-------|-----|------|
+| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
+| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
+| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
+| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |

+ 66 - 0
packages/vue/package.json

@@ -0,0 +1,66 @@
+{
+  "name": "@json-render/vue",
+  "version": "0.9.1",
+  "license": "Apache-2.0",
+  "description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
+  "keywords": [
+    "json",
+    "ui",
+    "vue",
+    "ai",
+    "generative-ui",
+    "llm",
+    "renderer",
+    "streaming",
+    "components"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/vue"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "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"
+    },
+    "./schema": {
+      "types": "./dist/schema.d.ts",
+      "import": "./dist/schema.mjs",
+      "require": "./dist/schema.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@vue/test-utils": "^2.4.6",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "vue": "^3.5.0"
+  },
+  "peerDependencies": {
+    "vue": "^3.5.0",
+    "zod": "^4.0.0"
+  }
+}

+ 152 - 0
packages/vue/src/catalog-types.ts

@@ -0,0 +1,152 @@
+import type { VNode } from "vue";
+import type {
+  Catalog,
+  InferCatalogComponents,
+  InferCatalogActions,
+  InferComponentProps,
+  InferActionParams,
+  StateModel,
+} from "@json-render/core";
+
+export type { StateModel };
+
+// =============================================================================
+// State Types
+// =============================================================================
+
+/**
+ * State setter function for updating application state
+ */
+export type SetState = (
+  updater: (prev: Record<string, unknown>) => Record<string, unknown>,
+) => void;
+
+// =============================================================================
+// Component Types
+// =============================================================================
+
+/**
+ * Handle returned by the `on()` function for a specific event.
+ * Provides metadata about the event binding and a method to fire it.
+ *
+ * @example
+ * ```ts
+ * const press = on("press");
+ * if (press.shouldPreventDefault) e.preventDefault();
+ * press.emit();
+ * ```
+ */
+export interface EventHandle {
+  /** Fire the event (resolve action bindings) */
+  emit: () => void;
+  /** Whether any binding requested preventDefault */
+  shouldPreventDefault: boolean;
+  /** Whether any handler is bound to this event */
+  bound: boolean;
+}
+
+/**
+ * Catalog-agnostic base type for component render function arguments.
+ * Use this when building reusable component libraries that are not tied to a specific catalog.
+ *
+ * @example
+ * ```ts
+ * const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
+ *   h('div', null, [props.title, children])
+ * ```
+ */
+export interface BaseComponentProps<P = Record<string, unknown>> {
+  props: P;
+  /** Rendered children (from the default slot) */
+  children?: VNode | VNode[];
+  /** Simple event emitter (shorthand). Fires the event and returns void. */
+  emit: (event: string) => void;
+  /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
+  on: (event: string) => EventHandle;
+  /**
+   * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
+   * Maps prop name → absolute state path for write-back.
+   */
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+
+/**
+ * Context passed to component render functions
+ * @example
+ * const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
+ *   return h('button', { onClick: () => ctx.emit("press") }, ctx.props.label)
+ * }
+ */
+export interface ComponentContext<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> extends BaseComponentProps<InferComponentProps<C, K>> {}
+
+/**
+ * Component render function type for Vue
+ * @example
+ * const Button: ComponentFn<typeof catalog, 'Button'> = ({ props, emit }) =>
+ *   h('button', { onClick: () => emit("press") }, props.label)
+ */
+export type ComponentFn<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null | string;
+
+/**
+ * Registry of all component render functions for a catalog
+ * @example
+ * const components: Components<typeof myCatalog> = {
+ *   Button: ({ props }) => h('button', null, props.label),
+ *   Input: ({ props }) => h('input', { placeholder: props.placeholder }),
+ * };
+ */
+export type Components<C extends Catalog> = {
+  [K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
+};
+
+// =============================================================================
+// Action Types
+// =============================================================================
+
+/**
+ * Action handler function type
+ * @example
+ * const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
+ *   const data = await fetch('/api/customers');
+ *   setState(prev => ({ ...prev, customers: data }));
+ * };
+ */
+export type ActionFn<
+  C extends Catalog,
+  K extends keyof InferCatalogActions<C>,
+> = (
+  params: InferActionParams<C, K> | undefined,
+  setState: SetState,
+  state: StateModel,
+) => Promise<void>;
+
+/**
+ * Registry of all action handlers for a catalog
+ * @example
+ * const actions: Actions<typeof myCatalog> = {
+ *   viewCustomers: async (params, setState) => { ... },
+ *   createCustomer: async (params, setState) => { ... },
+ * };
+ */
+export type Actions<C extends Catalog> = {
+  [K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
+};
+
+/**
+ * True when the catalog declares at least one action, false otherwise.
+ * Used by defineRegistry to conditionally require the `actions` field.
+ */
+export type CatalogHasActions<C extends Catalog> = [
+  InferCatalogActions<C>,
+] extends [never]
+  ? false
+  : [keyof InferCatalogActions<C>] extends [never]
+    ? false
+    : true;

+ 106 - 0
packages/vue/src/composables/actions.test.ts

@@ -0,0 +1,106 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import { StateProvider, useStateStore } from "./state";
+import { ActionProvider, useActions, useAction } from "./actions";
+
+/** Mount StateProvider → ActionProvider with a child that captures context. */
+function withProviders<T>(
+  composable: () => T,
+  handlers: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<void>
+  > = {},
+  initialState: Record<string, unknown> = {},
+): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: { initialState } as any,
+    slots: {
+      default: () =>
+        h(ActionProvider as Component, { handlers } as any, {
+          default: () => h(Child),
+        }),
+    },
+  });
+  return { result };
+}
+
+describe("ActionProvider — provide/inject", () => {
+  it("useActions() throws outside a provider", () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+    expect(() => useActions()).toThrow(
+      "useActions must be used within an ActionProvider",
+    );
+    warn.mockRestore();
+  });
+});
+
+describe("ActionProvider — custom handler dispatch", () => {
+  it("calling execute invokes the registered handler", async () => {
+    const handler = vi.fn().mockResolvedValue(undefined);
+    const { result } = withProviders(() => useActions(), { myAction: handler });
+    await result.execute({ action: "myAction", params: { x: 1 } });
+    expect(handler).toHaveBeenCalledOnce();
+  });
+
+  it("handler receives the resolved params", async () => {
+    const handler = vi.fn().mockResolvedValue(undefined);
+    const { result } = withProviders(() => useActions(), { myAction: handler });
+    await result.execute({ action: "myAction", params: { x: 1, y: "hello" } });
+    expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
+  });
+
+  it("console.warn is called for unknown actions", async () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const { result } = withProviders(() => useActions());
+    await result.execute({ action: "unknownAction" });
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknownAction"));
+    warn.mockRestore();
+  });
+});
+
+describe("ActionProvider — built-in setState integration", () => {
+  it("executing setState updates state via the provider chain", async () => {
+    let stateCtx!: ReturnType<typeof useStateStore>;
+    let actionsCtx!: ReturnType<typeof useActions>;
+
+    const Child = defineComponent({
+      setup() {
+        stateCtx = useStateStore();
+        actionsCtx = useActions();
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { v: 0 } } as any,
+      slots: {
+        default: () =>
+          h(ActionProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    await actionsCtx.execute({
+      action: "setState",
+      params: { statePath: "/v", value: 42 },
+    });
+    expect(stateCtx.state.value).toEqual({ v: 42 });
+  });
+});
+
+describe("useAction", () => {
+  it("returns { execute, isLoading: false } before execution", () => {
+    const { result } = withProviders(() => useAction({ action: "myAction" }));
+    expect(typeof result.execute).toBe("function");
+    expect(result.isLoading.value).toBe(false);
+  });
+});

+ 462 - 0
packages/vue/src/composables/actions.ts

@@ -0,0 +1,462 @@
+import {
+  computed,
+  defineComponent,
+  h,
+  inject,
+  provide,
+  ref,
+  watch,
+  type ComputedRef,
+  type PropType,
+} from "vue";
+import {
+  resolveAction,
+  executeAction,
+  type ActionBinding,
+  type ActionHandler,
+  type ActionConfirm,
+  type ResolvedAction,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+/**
+ * Generate a unique ID for use with the "$id" token.
+ */
+let idCounter = 0;
+function generateUniqueId(): string {
+  idCounter += 1;
+  return `${Date.now()}-${idCounter}`;
+}
+
+/**
+ * Deep-resolve dynamic value references within an object.
+ *
+ * Supported tokens:
+ * - `{ $state: "/statePath" }` - read a value from state
+ * - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
+ */
+function deepResolveValue(
+  value: unknown,
+  get: (path: string) => unknown,
+): unknown {
+  if (value === null || value === undefined) return value;
+
+  if (value === "$id") {
+    return generateUniqueId();
+  }
+
+  if (typeof value === "object" && !Array.isArray(value)) {
+    const obj = value as Record<string, unknown>;
+    const keys = Object.keys(obj);
+
+    if (keys.length === 1 && typeof obj.$state === "string") {
+      return get(obj.$state as string);
+    }
+
+    if (keys.length === 1 && "$id" in obj) {
+      return generateUniqueId();
+    }
+  }
+
+  if (Array.isArray(value)) {
+    return value.map((item) => deepResolveValue(item, get));
+  }
+
+  if (typeof value === "object") {
+    const resolved: Record<string, unknown> = {};
+    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
+      resolved[key] = deepResolveValue(val, get);
+    }
+    return resolved;
+  }
+
+  return value;
+}
+
+/**
+ * Pending confirmation state
+ */
+export interface PendingConfirmation {
+  action: ResolvedAction;
+  handler: ActionHandler;
+  resolve: () => void;
+  reject: () => void;
+}
+
+/**
+ * Action context value
+ */
+export interface ActionContextValue {
+  handlers: Record<string, ActionHandler>;
+  loadingActions: Set<string>;
+  pendingConfirmation: PendingConfirmation | null;
+  execute: (binding: ActionBinding) => Promise<void>;
+  confirm: () => void;
+  cancel: () => void;
+  registerHandler: (name: string, handler: ActionHandler) => void;
+}
+
+const ACTIONS_KEY = Symbol("json-render:actions");
+
+export interface ActionProviderProps {
+  handlers?: Record<string, ActionHandler>;
+  navigate?: (path: string) => void;
+}
+
+/**
+ * Provider for action execution
+ */
+export const ActionProvider = defineComponent({
+  name: "ActionProvider",
+  props: {
+    handlers: {
+      type: Object as PropType<Record<string, ActionHandler>>,
+      default: () => ({}),
+    },
+    navigate: {
+      type: Function as PropType<(path: string) => void>,
+      default: undefined,
+    },
+  },
+  setup(props, { slots }) {
+    const { get, set, getSnapshot } = useStateStore();
+
+    const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
+    const loadingActions = ref<Set<string>>(new Set());
+    const pendingConfirmation = ref<PendingConfirmation | null>(null);
+
+    // Sync handlers when prop changes
+    watch(
+      () => props.handlers,
+      (newHandlers) => {
+        if (newHandlers) handlers.value = newHandlers;
+      },
+    );
+
+    const registerHandler = (name: string, handler: ActionHandler) => {
+      handlers.value = { ...handlers.value, [name]: handler };
+    };
+
+    const execute = async (binding: ActionBinding): Promise<void> => {
+      const resolved = resolveAction(binding, getSnapshot());
+
+      // Built-in: setState
+      if (resolved.action === "setState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const value = resolved.params.value;
+        if (statePath) {
+          set(statePath, value);
+        }
+        return;
+      }
+
+      // Built-in: pushState
+      if (resolved.action === "pushState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const rawValue = resolved.params.value;
+        if (statePath) {
+          const resolvedValue = deepResolveValue(rawValue, get);
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(statePath, [...arr, resolvedValue]);
+          const clearStatePath = resolved.params.clearStatePath as
+            | string
+            | undefined;
+          if (clearStatePath) {
+            set(clearStatePath, "");
+          }
+        }
+        return;
+      }
+
+      // Built-in: removeState
+      if (resolved.action === "removeState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const index = resolved.params.index as number;
+        if (statePath !== undefined && index !== undefined) {
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(
+            statePath,
+            arr.filter((_, i) => i !== index),
+          );
+        }
+        return;
+      }
+
+      // Built-in: push (navigation)
+      if (resolved.action === "push" && resolved.params) {
+        const screen = resolved.params.screen as string;
+        if (screen) {
+          const currentScreen = get("/currentScreen") as string | undefined;
+          const navStack = (get("/navStack") as string[] | undefined) ?? [];
+          if (currentScreen) {
+            set("/navStack", [...navStack, currentScreen]);
+          } else {
+            set("/navStack", [...navStack, ""]);
+          }
+          set("/currentScreen", screen);
+        }
+        return;
+      }
+
+      // Built-in: pop (navigation)
+      if (resolved.action === "pop") {
+        const navStack = (get("/navStack") as string[] | undefined) ?? [];
+        if (navStack.length > 0) {
+          const previousScreen = navStack[navStack.length - 1];
+          set("/navStack", navStack.slice(0, -1));
+          if (previousScreen) {
+            set("/currentScreen", previousScreen);
+          } else {
+            set("/currentScreen", undefined);
+          }
+        }
+        return;
+      }
+
+      const handler = handlers.value[resolved.action];
+
+      if (!handler) {
+        console.warn(`No handler registered for action: ${resolved.action}`);
+        return;
+      }
+
+      // If confirmation is required, show dialog first
+      if (resolved.confirm) {
+        await new Promise<void>((resolve, reject) => {
+          pendingConfirmation.value = {
+            action: resolved,
+            handler,
+            resolve: () => {
+              pendingConfirmation.value = null;
+              resolve();
+            },
+            reject: () => {
+              pendingConfirmation.value = null;
+              reject(new Error("Action cancelled"));
+            },
+          };
+        });
+
+        const addLoading = new Set(loadingActions.value);
+        addLoading.add(resolved.action);
+        loadingActions.value = addLoading;
+        try {
+          await executeAction({
+            action: resolved,
+            handler,
+            setState: set,
+            navigate: props.navigate,
+            executeAction: async (name) => {
+              const subBinding: ActionBinding = { action: name };
+              await execute(subBinding);
+            },
+          });
+        } finally {
+          const removeLoading = new Set(loadingActions.value);
+          removeLoading.delete(resolved.action);
+          loadingActions.value = removeLoading;
+        }
+        return;
+      }
+
+      // Execute immediately
+      const addLoading = new Set(loadingActions.value);
+      addLoading.add(resolved.action);
+      loadingActions.value = addLoading;
+      try {
+        await executeAction({
+          action: resolved,
+          handler,
+          setState: set,
+          navigate: props.navigate,
+          executeAction: async (name) => {
+            const subBinding: ActionBinding = { action: name };
+            await execute(subBinding);
+          },
+        });
+      } finally {
+        const removeLoading = new Set(loadingActions.value);
+        removeLoading.delete(resolved.action);
+        loadingActions.value = removeLoading;
+      }
+    };
+
+    const confirm = () => pendingConfirmation.value?.resolve();
+    const cancel = () => pendingConfirmation.value?.reject();
+
+    provide<ActionContextValue>(ACTIONS_KEY, {
+      get handlers() {
+        return handlers.value;
+      },
+      get loadingActions() {
+        return loadingActions.value;
+      },
+      get pendingConfirmation() {
+        return pendingConfirmation.value;
+      },
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    });
+
+    return () => slots.default?.();
+  },
+});
+
+/**
+ * Composable to access action context
+ */
+export function useActions(): ActionContextValue {
+  const ctx = inject<ActionContextValue>(ACTIONS_KEY);
+  if (!ctx) {
+    throw new Error("useActions must be used within an ActionProvider");
+  }
+  return ctx;
+}
+
+/**
+ * Composable to execute an action binding
+ */
+export function useAction(binding: ActionBinding): {
+  execute: () => Promise<void>;
+  isLoading: ComputedRef<boolean>;
+} {
+  const ctx = useActions();
+  return {
+    execute: () => ctx.execute(binding),
+    isLoading: computed(() => ctx.loadingActions.has(binding.action)),
+  };
+}
+
+// =============================================================================
+// ConfirmDialog component
+// =============================================================================
+
+/**
+ * Props for ConfirmDialog component
+ */
+export interface ConfirmDialogProps {
+  confirm: ActionConfirm;
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+/**
+ * Default confirmation dialog component
+ */
+export const ConfirmDialog = defineComponent({
+  name: "ConfirmDialog",
+  props: {
+    confirm: {
+      type: Object as PropType<ActionConfirm>,
+      required: true,
+    },
+    onConfirm: {
+      type: Function as PropType<() => void>,
+      required: true,
+    },
+    onCancel: {
+      type: Function as PropType<() => void>,
+      required: true,
+    },
+  },
+  setup(props) {
+    return () => {
+      const isDanger = props.confirm.variant === "danger";
+
+      return h(
+        "div",
+        {
+          style: {
+            position: "fixed",
+            inset: "0",
+            backgroundColor: "rgba(0, 0, 0, 0.5)",
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            zIndex: "50",
+          },
+          onClick: props.onCancel,
+        },
+        [
+          h(
+            "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: MouseEvent) => e.stopPropagation(),
+            },
+            [
+              h(
+                "h3",
+                {
+                  style: {
+                    margin: "0 0 8px 0",
+                    fontSize: "18px",
+                    fontWeight: "600",
+                  },
+                },
+                props.confirm.title,
+              ),
+              h(
+                "p",
+                {
+                  style: { margin: "0 0 24px 0", color: "#6b7280" },
+                },
+                props.confirm.message,
+              ),
+              h(
+                "div",
+                {
+                  style: {
+                    display: "flex",
+                    gap: "12px",
+                    justifyContent: "flex-end",
+                  },
+                },
+                [
+                  h(
+                    "button",
+                    {
+                      style: {
+                        padding: "8px 16px",
+                        borderRadius: "6px",
+                        border: "1px solid #d1d5db",
+                        backgroundColor: "white",
+                        cursor: "pointer",
+                      },
+                      onClick: props.onCancel,
+                    },
+                    props.confirm.cancelLabel ?? "Cancel",
+                  ),
+                  h(
+                    "button",
+                    {
+                      style: {
+                        padding: "8px 16px",
+                        borderRadius: "6px",
+                        border: "none",
+                        backgroundColor: isDanger ? "#dc2626" : "#3b82f6",
+                        color: "white",
+                        cursor: "pointer",
+                      },
+                      onClick: props.onConfirm,
+                    },
+                    props.confirm.confirmLabel ?? "Confirm",
+                  ),
+                ],
+              ),
+            ],
+          ),
+        ],
+      );
+    };
+  },
+});

+ 51 - 0
packages/vue/src/composables/repeat-scope.ts

@@ -0,0 +1,51 @@
+import { defineComponent, inject, provide, type PropType } from "vue";
+
+/**
+ * Repeat scope value provided to child elements inside a repeated element.
+ */
+export interface RepeatScopeValue {
+  /** The current array item object */
+  item: unknown;
+  /** Index of the current item in the array */
+  index: number;
+  /** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
+  basePath: string;
+}
+
+const REPEAT_SCOPE_KEY = Symbol("json-render:repeat-scope");
+
+/**
+ * Provides repeat scope to child elements so $item and $index expressions resolve correctly.
+ */
+export const RepeatScopeProvider = defineComponent({
+  name: "RepeatScopeProvider",
+  props: {
+    item: {
+      required: true,
+    },
+    index: {
+      type: Number,
+      required: true,
+    },
+    basePath: {
+      type: String as PropType<string>,
+      required: true,
+    },
+  },
+  setup(props, { slots }) {
+    provide(REPEAT_SCOPE_KEY, props as RepeatScopeValue);
+    return () => slots.default?.();
+  },
+});
+
+/**
+ * Read the current repeat scope (or null if not inside a repeated element).
+ */
+export function useRepeatScope(): RepeatScopeValue | null {
+  return (
+    inject<RepeatScopeValue>(
+      REPEAT_SCOPE_KEY,
+      null as unknown as RepeatScopeValue,
+    ) ?? null
+  );
+}

+ 139 - 0
packages/vue/src/composables/state.test.ts

@@ -0,0 +1,139 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import { createStateStore } from "@json-render/core";
+import { StateProvider, useStateStore } from "./state";
+
+/** Mount a StateProvider with a child that captures the injected context. */
+function withProvider<T>(
+  composable: () => T,
+  props: Record<string, unknown> = {},
+): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: props as any,
+    slots: { default: () => h(Child) },
+  });
+  return { result };
+}
+
+describe("StateProvider — provide/inject", () => {
+  it("useStateStore() throws outside a provider", () => {
+    // inject() returns undefined outside of component setup; our guard throws
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+    expect(() => useStateStore()).toThrow(
+      "useStateStore must be used within a StateProvider",
+    );
+    warn.mockRestore();
+  });
+
+  it("child receives the context from StateProvider", () => {
+    const { result } = withProvider(() => useStateStore());
+    expect(result).toBeDefined();
+    expect(result.state).toBeDefined();
+    expect(typeof result.get).toBe("function");
+    expect(typeof result.set).toBe("function");
+    expect(typeof result.update).toBe("function");
+  });
+
+  it("state.value is a plain object (not a wrapper type)", () => {
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: { x: 1 },
+    });
+    expect(result.state.value).toEqual({ x: 1 });
+    // Should be a plain object, not a Vue Proxy of a ShallowRef wrapper
+    expect(typeof result.state.value).toBe("object");
+  });
+});
+
+describe("StateProvider (uncontrolled) — reactivity", () => {
+  it("state.value reflects initialState on mount", () => {
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: { count: 5 },
+    });
+    expect(result.state.value).toEqual({ count: 5 });
+  });
+
+  it("after set(), state.value is updated synchronously", () => {
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: { x: 0 },
+    });
+    result.set("/x", 42);
+    expect(result.state.value).toEqual({ x: 42 });
+  });
+
+  it("after update(), all paths are reflected in state.value", () => {
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: {},
+    });
+    result.update({ "/a": 1, "/b": "hello" });
+    expect(result.state.value).toEqual({ a: 1, b: "hello" });
+  });
+
+  it("onStateChange is fired with the changes array on set", () => {
+    const onChange = vi.fn();
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: {},
+      onStateChange: onChange,
+    });
+    result.set("/name", "Alice");
+    expect(onChange).toHaveBeenCalledOnce();
+    expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
+  });
+
+  it("onStateChange is fired once with all changed entries on update", () => {
+    const onChange = vi.fn();
+    const { result } = withProvider(() => useStateStore(), {
+      initialState: {},
+      onStateChange: onChange,
+    });
+    result.update({ "/a": 1, "/b": 2 });
+    expect(onChange).toHaveBeenCalledOnce();
+    const [changes] = onChange.mock.calls[0]!;
+    expect(changes).toEqual(
+      expect.arrayContaining([
+        { path: "/a", value: 1 },
+        { path: "/b", value: 2 },
+      ]),
+    );
+  });
+});
+
+describe("StateProvider (controlled mode)", () => {
+  it("state.value reads from the external store snapshot", () => {
+    const store = createStateStore({ x: 10 });
+    const { result } = withProvider(() => useStateStore(), { store });
+    expect(result.state.value).toEqual({ x: 10 });
+  });
+
+  it("set() writes through to the external store", () => {
+    const store = createStateStore({ x: 0 });
+    const { result } = withProvider(() => useStateStore(), { store });
+    result.set("/x", 99);
+    expect(store.getSnapshot()).toEqual({ x: 99 });
+  });
+
+  it("external store mutation triggers state.value update", () => {
+    const store = createStateStore({ x: 0 });
+    const { result } = withProvider(() => useStateStore(), { store });
+    store.set("/x", 99);
+    expect(result.state.value).toEqual({ x: 99 });
+  });
+
+  it("onStateChange is NOT called in controlled mode", () => {
+    const store = createStateStore({ x: 0 });
+    const onChange = vi.fn();
+    const { result } = withProvider(() => useStateStore(), {
+      store,
+      onStateChange: onChange,
+    });
+    result.set("/x", 99);
+    expect(onChange).not.toHaveBeenCalled();
+  });
+});

+ 216 - 0
packages/vue/src/composables/state.ts

@@ -0,0 +1,216 @@
+import {
+  computed,
+  defineComponent,
+  inject,
+  onUnmounted,
+  provide,
+  ref,
+  shallowRef,
+  watch,
+  type ComputedRef,
+  type PropType,
+  type ShallowRef,
+} from "vue";
+import {
+  createStateStore,
+  getByPath,
+  type StateModel,
+  type StateStore,
+} from "@json-render/core";
+import { flattenToPointers } from "@json-render/core/store-utils";
+
+/**
+ * State context value
+ */
+export interface StateContextValue {
+  /** Reactive state snapshot — use state.value in reactive contexts */
+  state: ShallowRef<StateModel>;
+  /** 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;
+  /** Return the live state snapshot from the underlying store (not through Vue reactivity). */
+  getSnapshot: () => StateModel;
+}
+
+const STATE_KEY = Symbol("json-render:state");
+
+/**
+ * Props for StateProvider
+ */
+export interface StateProviderProps {
+  /**
+   * External store that owns the state. When provided, the provider operates
+   * in **controlled mode** — `initialState` and `onStateChange` are ignored
+   * and the store is the single source of truth.
+   */
+  store?: StateStore;
+  /** Initial state model (used only in uncontrolled mode) */
+  initialState?: StateModel;
+  /**
+   * Callback when state changes (used only in uncontrolled mode).
+   * Called once per `set` or `update` with all changed entries.
+   */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+}
+
+/**
+ * Provider for state model context.
+ *
+ * Supports two modes:
+ * - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
+ * - **Uncontrolled** (default): omit `store` and optionally pass
+ *   `initialState` / `onStateChange`.
+ */
+export const StateProvider = defineComponent({
+  name: "StateProvider",
+  props: {
+    store: {
+      type: Object as PropType<StateStore>,
+      default: undefined,
+    },
+    initialState: {
+      type: Object as PropType<StateModel>,
+      default: undefined,
+    },
+    onStateChange: {
+      type: Function as PropType<
+        (changes: Array<{ path: string; value: unknown }>) => void
+      >,
+      default: undefined,
+    },
+  },
+  setup(props, { slots }) {
+    const isControlled = !!props.store;
+
+    // Use external store (controlled) or create internal store (uncontrolled)
+    const internalStore = !isControlled
+      ? createStateStore(props.initialState ?? {})
+      : null;
+    const store: StateStore = props.store ?? internalStore!;
+
+    const state = shallowRef<StateModel>(store.getSnapshot());
+
+    const unsubscribe = store.subscribe(() => {
+      state.value = store.getSnapshot();
+    });
+    onUnmounted(unsubscribe);
+
+    // Sync external initialState changes (uncontrolled mode only)
+    if (!isControlled) {
+      let prevFlat: Record<string, unknown> =
+        props.initialState && Object.keys(props.initialState).length > 0
+          ? flattenToPointers(props.initialState)
+          : {};
+
+      watch(
+        () => props.initialState,
+        (newInitialState) => {
+          if (!newInitialState) return;
+          const nextFlat =
+            Object.keys(newInitialState).length > 0
+              ? flattenToPointers(newInitialState)
+              : {};
+          const allKeys = new Set([
+            ...Object.keys(prevFlat),
+            ...Object.keys(nextFlat),
+          ]);
+          const updates: Record<string, unknown> = {};
+          for (const key of allKeys) {
+            if (prevFlat[key] !== nextFlat[key]) {
+              updates[key] = key in nextFlat ? nextFlat[key] : undefined;
+            }
+          }
+          prevFlat = nextFlat;
+          if (Object.keys(updates).length > 0) {
+            store.update(updates);
+          }
+        },
+      );
+    }
+
+    // Keep onStateChange in a ref so it always reads the latest callback
+    const onStateChangeRef = ref(props.onStateChange);
+    watch(
+      () => props.onStateChange,
+      (fn) => {
+        onStateChangeRef.value = fn;
+      },
+    );
+
+    const get = (path: string) => store.get(path);
+    const getSnapshot = () => store.getSnapshot();
+
+    const set = (path: string, value: unknown) => {
+      const prev = store.getSnapshot();
+      store.set(path, value);
+      if (!isControlled && store.getSnapshot() !== prev) {
+        onStateChangeRef.value?.([{ path, value }]);
+      }
+    };
+
+    const update = (updates: Record<string, unknown>) => {
+      const prev = store.getSnapshot();
+      store.update(updates);
+      if (!isControlled && store.getSnapshot() !== prev) {
+        const changes: Array<{ path: string; value: unknown }> = [];
+        for (const [path, value] of Object.entries(updates)) {
+          if (getByPath(prev, path) !== value) {
+            changes.push({ path, value });
+          }
+        }
+        if (changes.length > 0) {
+          onStateChangeRef.value?.(changes);
+        }
+      }
+    };
+
+    provide<StateContextValue>(STATE_KEY, {
+      state,
+      get,
+      set,
+      update,
+      getSnapshot,
+    });
+
+    return () => slots.default?.();
+  },
+});
+
+/**
+ * Composable to access the state context
+ */
+export function useStateStore(): StateContextValue {
+  const ctx = inject<StateContextValue>(STATE_KEY);
+  if (!ctx) {
+    throw new Error("useStateStore must be used within a StateProvider");
+  }
+  return ctx;
+}
+
+/**
+ * Composable to get a value from the state model (reactive)
+ */
+export function useStateValue<T>(path: string): ComputedRef<T | undefined> {
+  const { state } = useStateStore();
+  return computed(() => getByPath(state.value, path) as T | undefined);
+}
+
+/**
+ * Composable to get and set a value from the state model by path.
+ *
+ * This is the path-based variant for use in arbitrary composables. For
+ * registry components that receive `bindings` from the renderer, prefer
+ * `useBoundProp` which reads the already-resolved prop value and writes back
+ * to the bound path.
+ */
+export function useStateBinding<T>(
+  path: string,
+): [ComputedRef<T | undefined>, (value: T) => void] {
+  const { state, set } = useStateStore();
+  const value = computed(() => getByPath(state.value, path) as T | undefined);
+  const setValue = (newValue: T) => set(path, newValue);
+  return [value, setValue];
+}

+ 209 - 0
packages/vue/src/composables/validation.test.ts

@@ -0,0 +1,209 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import { StateProvider } from "./state";
+import {
+  ValidationProvider,
+  useOptionalValidation,
+  useValidation,
+  useFieldValidation,
+} from "./validation";
+
+/** Mount StateProvider → ValidationProvider with a child that captures context. */
+function withProviders<T>(
+  composable: () => T,
+  initialState: Record<string, unknown> = {},
+): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: { initialState } as any,
+    slots: {
+      default: () =>
+        h(ValidationProvider as Component, null, {
+          default: () => h(Child),
+        }),
+    },
+  });
+  return { result };
+}
+
+/** Mount only inside StateProvider (no ValidationProvider) */
+function withStateOnly<T>(composable: () => T): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: { initialState: {} } as any,
+    slots: { default: () => h(Child) },
+  });
+  return { result };
+}
+
+describe("ValidationProvider — provide/inject", () => {
+  it("useValidation() throws outside a provider", () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+    expect(() => useValidation()).toThrow(
+      "useValidation must be used within a ValidationProvider",
+    );
+    warn.mockRestore();
+  });
+});
+
+describe("useOptionalValidation", () => {
+  it("returns null outside a ValidationProvider", () => {
+    const { result } = withStateOnly(() => useOptionalValidation());
+    expect(result).toBeNull();
+  });
+
+  it("returns context inside a ValidationProvider", () => {
+    const { result } = withProviders(() => useOptionalValidation());
+    expect(result).not.toBeNull();
+    expect(typeof result!.validate).toBe("function");
+    expect(typeof result!.validateAll).toBe("function");
+  });
+});
+
+describe("useFieldValidation — lifecycle", () => {
+  it("after validate(), isValid and errors reflect the result", () => {
+    const { result } = withProviders(
+      () =>
+        useFieldValidation("/name", {
+          checks: [{ type: "required", message: "Name is required" }],
+        }),
+      { name: "" },
+    );
+    const validationResult = result.validate();
+    expect(validationResult.valid).toBe(false);
+    expect(validationResult.errors).toContain("Name is required");
+  });
+
+  it("after validate() with valid value, isValid is true", () => {
+    const { result } = withProviders(
+      () =>
+        useFieldValidation("/name", {
+          checks: [{ type: "required", message: "Name is required" }],
+        }),
+      { name: "Alice" },
+    );
+    const validationResult = result.validate();
+    expect(validationResult.valid).toBe(true);
+    expect(validationResult.errors).toHaveLength(0);
+  });
+
+  it("touch() sets touched: true in the validation context", () => {
+    let validationCtx!: ReturnType<typeof useValidation>;
+    let fieldCtx!: ReturnType<typeof useFieldValidation>;
+
+    const Child = defineComponent({
+      setup() {
+        validationCtx = useValidation();
+        fieldCtx = useFieldValidation("/email");
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: {} } as any,
+      slots: {
+        default: () =>
+          h(ValidationProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    fieldCtx.touch();
+    expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
+  });
+
+  it("clear() resets the field state from the validation context", () => {
+    let validationCtx!: ReturnType<typeof useValidation>;
+    let fieldCtx!: ReturnType<typeof useFieldValidation>;
+
+    const Child = defineComponent({
+      setup() {
+        validationCtx = useValidation();
+        fieldCtx = useFieldValidation("/email", {
+          checks: [{ type: "required", message: "Required" }],
+        });
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { email: "" } } as any,
+      slots: {
+        default: () =>
+          h(ValidationProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    fieldCtx.validate(); // populate fieldStates
+    fieldCtx.clear();
+    expect(validationCtx.fieldStates["/email"]).toBeUndefined();
+  });
+
+  it("validateAll() returns true when all registered fields pass", () => {
+    let validationCtx!: ReturnType<typeof useValidation>;
+
+    const Child = defineComponent({
+      setup() {
+        validationCtx = useValidation();
+        useFieldValidation("/name", {
+          checks: [{ type: "required", message: "Required" }],
+        });
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { name: "Alice" } } as any,
+      slots: {
+        default: () =>
+          h(ValidationProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    expect(validationCtx.validateAll()).toBe(true);
+  });
+
+  it("validateAll() returns false when any field fails", () => {
+    let validationCtx!: ReturnType<typeof useValidation>;
+
+    const Child = defineComponent({
+      setup() {
+        validationCtx = useValidation();
+        useFieldValidation("/name", {
+          checks: [{ type: "required", message: "Required" }],
+        });
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { name: "" } } as any,
+      slots: {
+        default: () =>
+          h(ValidationProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    expect(validationCtx.validateAll()).toBe(false);
+  });
+});

+ 267 - 0
packages/vue/src/composables/validation.ts

@@ -0,0 +1,267 @@
+import {
+  computed,
+  defineComponent,
+  inject,
+  onMounted,
+  onUnmounted,
+  provide,
+  ref,
+  type ComputedRef,
+  type PropType,
+} from "vue";
+import {
+  runValidation,
+  type ValidationConfig,
+  type ValidationFunction,
+  type ValidationResult,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+/**
+ * Field validation state
+ */
+export interface FieldValidationState {
+  touched: boolean;
+  validated: boolean;
+  result: ValidationResult | null;
+}
+
+/**
+ * Validation context value
+ */
+export interface ValidationContextValue {
+  customFunctions: Record<string, ValidationFunction>;
+  fieldStates: Record<string, FieldValidationState>;
+  validate: (path: string, config: ValidationConfig) => ValidationResult;
+  touch: (path: string) => void;
+  clear: (path: string) => void;
+  validateAll: () => boolean;
+  registerField: (path: string, config: ValidationConfig) => void;
+}
+
+const VALIDATION_KEY = Symbol("json-render:validation");
+
+export interface ValidationProviderProps {
+  customFunctions?: Record<string, ValidationFunction>;
+}
+
+/**
+ * Compare two args records shallowly.
+ */
+function dynamicArgsEqual(
+  a: Record<string, unknown> | undefined,
+  b: Record<string, unknown> | undefined,
+): boolean {
+  if (a === b) return true;
+  if (!a || !b) return false;
+  const keysA = Object.keys(a);
+  const keysB = Object.keys(b);
+  if (keysA.length !== keysB.length) return false;
+  for (const key of keysA) {
+    const va = a[key];
+    const vb = b[key];
+    if (va === vb) continue;
+    if (
+      typeof va === "object" &&
+      va !== null &&
+      typeof vb === "object" &&
+      vb !== null
+    ) {
+      const sa = (va as Record<string, unknown>).$state;
+      const sb = (vb as Record<string, unknown>).$state;
+      if (typeof sa === "string" && sa === sb) continue;
+    }
+    return false;
+  }
+  return true;
+}
+
+/**
+ * Structural equality check for ValidationConfig.
+ */
+function validationConfigEqual(
+  a: ValidationConfig,
+  b: ValidationConfig,
+): boolean {
+  if (a === b) return true;
+  if (a.validateOn !== b.validateOn) return false;
+  const ac = a.checks ?? [];
+  const bc = b.checks ?? [];
+  if (ac.length !== bc.length) return false;
+  for (let i = 0; i < ac.length; i++) {
+    const ca = ac[i]!;
+    const cb = bc[i]!;
+    if (ca.type !== cb.type) return false;
+    if (ca.message !== cb.message) return false;
+    if (!dynamicArgsEqual(ca.args, cb.args)) return false;
+  }
+  return true;
+}
+
+/**
+ * Provider for validation
+ */
+export const ValidationProvider = defineComponent({
+  name: "ValidationProvider",
+  props: {
+    customFunctions: {
+      type: Object as PropType<Record<string, ValidationFunction>>,
+      default: () => ({}),
+    },
+  },
+  setup(props, { slots }) {
+    const { state } = useStateStore();
+
+    const fieldStates = ref<Record<string, FieldValidationState>>({});
+    const fieldConfigs = ref<Record<string, ValidationConfig>>({});
+
+    const registerField = (path: string, config: ValidationConfig) => {
+      const existing = fieldConfigs.value[path];
+      if (existing && validationConfigEqual(existing, config)) return;
+      fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
+    };
+
+    const validate = (
+      path: string,
+      config: ValidationConfig,
+    ): ValidationResult => {
+      const currentState = state.value;
+      const segments = path.split("/").filter(Boolean);
+      let value: unknown = currentState;
+      for (const seg of segments) {
+        if (value != null && typeof value === "object") {
+          value = (value as Record<string, unknown>)[seg];
+        } else {
+          value = undefined;
+          break;
+        }
+      }
+      const result = runValidation(config, {
+        value,
+        stateModel: currentState,
+        customFunctions: props.customFunctions,
+      });
+
+      fieldStates.value = {
+        ...fieldStates.value,
+        [path]: {
+          touched: fieldStates.value[path]?.touched ?? true,
+          validated: true,
+          result,
+        },
+      };
+
+      return result;
+    };
+
+    const touch = (path: string) => {
+      fieldStates.value = {
+        ...fieldStates.value,
+        [path]: {
+          ...fieldStates.value[path],
+          touched: true,
+          validated: fieldStates.value[path]?.validated ?? false,
+          result: fieldStates.value[path]?.result ?? null,
+        },
+      };
+    };
+
+    const clear = (path: string) => {
+      const { [path]: _, ...rest } = fieldStates.value;
+      fieldStates.value = rest;
+    };
+
+    const validateAll = (): boolean => {
+      let allValid = true;
+      for (const [path, config] of Object.entries(fieldConfigs.value)) {
+        const result = validate(path, config);
+        if (!result.valid) {
+          allValid = false;
+        }
+      }
+      return allValid;
+    };
+
+    provide<ValidationContextValue>(VALIDATION_KEY, {
+      get customFunctions() {
+        return props.customFunctions;
+      },
+      get fieldStates() {
+        return fieldStates.value;
+      },
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    });
+
+    return () => slots.default?.();
+  },
+});
+
+/**
+ * Composable to access validation context (or null if not inside a ValidationProvider).
+ * Useful for components that optionally participate in form validation.
+ */
+export function useOptionalValidation(): ValidationContextValue | null {
+  return (
+    inject<ValidationContextValue>(
+      VALIDATION_KEY,
+      null as unknown as ValidationContextValue,
+    ) ?? null
+  );
+}
+
+/**
+ * Composable to access validation context
+ */
+export function useValidation(): ValidationContextValue {
+  const ctx = inject<ValidationContextValue>(VALIDATION_KEY);
+  if (!ctx) {
+    throw new Error("useValidation must be used within a ValidationProvider");
+  }
+  return ctx;
+}
+
+/**
+ * Composable to get validation state for a field
+ */
+export function useFieldValidation(
+  path: string,
+  config?: ValidationConfig,
+): {
+  state: ComputedRef<FieldValidationState>;
+  validate: () => ValidationResult;
+  touch: () => void;
+  clear: () => void;
+  errors: ComputedRef<string[]>;
+  isValid: ComputedRef<boolean>;
+} {
+  const ctx = useValidation();
+
+  onMounted(() => {
+    if (path && config) {
+      ctx.registerField(path, config);
+    }
+  });
+
+  onUnmounted(() => {
+    ctx.clear(path);
+  });
+
+  const defaultState: FieldValidationState = {
+    touched: false,
+    validated: false,
+    result: null,
+  };
+
+  return {
+    state: computed(() => ctx.fieldStates[path] ?? defaultState),
+    validate: () => ctx.validate(path, config ?? { checks: [] }),
+    touch: () => ctx.touch(path),
+    clear: () => ctx.clear(path),
+    errors: computed(() => ctx.fieldStates[path]?.result?.errors ?? []),
+    isValid: computed(() => ctx.fieldStates[path]?.result?.valid ?? true),
+  };
+}

+ 97 - 0
packages/vue/src/composables/visibility.test.ts

@@ -0,0 +1,97 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, type Component, type ComputedRef } from "vue";
+import { mount } from "@vue/test-utils";
+import { StateProvider, useStateStore } from "./state";
+import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
+
+/** Mount StateProvider → VisibilityProvider with a child that captures context. */
+function withProviders<T>(
+  composable: () => T,
+  initialState: Record<string, unknown> = {},
+): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: { initialState } as any,
+    slots: {
+      default: () =>
+        h(VisibilityProvider as Component, null, {
+          default: () => h(Child),
+        }),
+    },
+  });
+  return { result };
+}
+
+describe("VisibilityProvider — provide/inject", () => {
+  it("useVisibility() throws outside a VisibilityProvider", () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+    expect(() => useVisibility()).toThrow(
+      "useVisibility must be used within a VisibilityProvider",
+    );
+    warn.mockRestore();
+  });
+
+  it("useVisibility() returns a context with isVisible and ctx", () => {
+    const { result } = withProviders(() => useVisibility());
+    expect(typeof result.isVisible).toBe("function");
+    expect(result.ctx).toBeDefined();
+    expect(result.ctx.value).toBeDefined();
+  });
+});
+
+describe("useIsVisible — state integration", () => {
+  it("undefined condition returns true", () => {
+    const { result } = withProviders(() => useVisibility());
+    expect(result.isVisible(undefined)).toBe(true);
+  });
+
+  it("{ $state: '/flag' } returns true when state flag is truthy", () => {
+    const { result } = withProviders(() => useVisibility(), { flag: true });
+    expect(result.isVisible({ $state: "/flag" })).toBe(true);
+  });
+
+  it("{ $state: '/flag' } returns false when state flag is falsy", () => {
+    const { result } = withProviders(() => useVisibility(), { flag: false });
+    expect(result.isVisible({ $state: "/flag" })).toBe(false);
+  });
+
+  it("ctx is a ComputedRef whose .value reflects current state", () => {
+    const { result } = withProviders(() => useVisibility(), { count: 3 });
+    expect(result.ctx.value.stateModel).toEqual({ count: 3 });
+  });
+});
+
+describe("useIsVisible — reactivity", () => {
+  it("returns a ComputedRef<boolean> that updates when state changes", () => {
+    let storeCtx!: ReturnType<typeof useStateStore>;
+    let isVisible!: ComputedRef<boolean>;
+
+    const Child = defineComponent({
+      setup() {
+        storeCtx = useStateStore();
+        isVisible = useIsVisible({ $state: "/flag" });
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { flag: false } } as any,
+      slots: {
+        default: () =>
+          h(VisibilityProvider as Component, null, {
+            default: () => h(Child),
+          }),
+      },
+    });
+
+    expect(isVisible.value).toBe(false);
+    storeCtx.set("/flag", true);
+    expect(isVisible.value).toBe(true);
+  });
+});

+ 68 - 0
packages/vue/src/composables/visibility.ts

@@ -0,0 +1,68 @@
+import {
+  computed,
+  defineComponent,
+  inject,
+  provide,
+  type ComputedRef,
+} from "vue";
+import {
+  evaluateVisibility,
+  type VisibilityCondition,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+/**
+ * Visibility context value
+ */
+export interface VisibilityContextValue {
+  /** Evaluate a visibility condition */
+  isVisible: (condition: VisibilityCondition | undefined) => boolean;
+  /** The underlying visibility context (reactive) */
+  ctx: ComputedRef<CoreVisibilityContext>;
+}
+
+const VISIBILITY_KEY = Symbol("json-render:visibility");
+
+/**
+ * Provider for visibility evaluation
+ */
+export const VisibilityProvider = defineComponent({
+  name: "VisibilityProvider",
+  setup(_, { slots }) {
+    const { state } = useStateStore();
+
+    const ctx = computed<CoreVisibilityContext>(() => ({
+      stateModel: state.value,
+    }));
+
+    const isVisible = (condition: VisibilityCondition | undefined): boolean =>
+      evaluateVisibility(condition, ctx.value);
+
+    provide<VisibilityContextValue>(VISIBILITY_KEY, { isVisible, ctx });
+
+    return () => slots.default?.();
+  },
+});
+
+/**
+ * Composable to access visibility evaluation
+ */
+export function useVisibility(): VisibilityContextValue {
+  const ctx = inject<VisibilityContextValue>(VISIBILITY_KEY);
+  if (!ctx) {
+    throw new Error("useVisibility must be used within a VisibilityProvider");
+  }
+  return ctx;
+}
+
+/**
+ * Composable to check if a condition is visible. Returns a reactive
+ * `ComputedRef<boolean>` so the result updates whenever state changes.
+ */
+export function useIsVisible(
+  condition: VisibilityCondition | undefined,
+): ComputedRef<boolean> {
+  const { ctx } = useVisibility();
+  return computed(() => evaluateVisibility(condition, ctx.value));
+}

+ 347 - 0
packages/vue/src/hooks.test.ts

@@ -0,0 +1,347 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { defineComponent, h, ref, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import { SPEC_DATA_PART_TYPE } from "@json-render/core";
+import { StateProvider, useStateStore } from "./composables/state";
+import {
+  flatToTree,
+  buildSpecFromParts,
+  getTextFromParts,
+  useBoundProp,
+  useUIStream,
+  useJsonRenderMessage,
+  type DataPart,
+} from "./hooks";
+
+// ---------------------------------------------------------------------------
+// Test helpers
+// ---------------------------------------------------------------------------
+
+/** Mount inside a StateProvider and capture a composable's result. */
+function withStateProvider<T>(
+  composable: () => T,
+  initialState: Record<string, unknown> = {},
+): { result: T } {
+  let result!: T;
+  const Child = defineComponent({
+    setup() {
+      result = composable();
+      return () => h("div");
+    },
+  });
+  mount(StateProvider as Component, {
+    props: { initialState } as any,
+    slots: { default: () => h(Child) },
+  });
+  return { result };
+}
+
+/** Create a simple ReadableStream from a string (for fetch mocks). */
+function makeReadableStream(text: string): ReadableStream<Uint8Array> {
+  const encoder = new TextEncoder();
+  return new ReadableStream({
+    start(controller) {
+      controller.enqueue(encoder.encode(text));
+      controller.close();
+    },
+  });
+}
+
+// ---------------------------------------------------------------------------
+// flatToTree
+// ---------------------------------------------------------------------------
+
+describe("flatToTree", () => {
+  it("converts flat elements into a Spec with root and children", () => {
+    const elements = [
+      { key: "root", type: "Stack", props: {}, parentKey: undefined },
+      {
+        key: "child1",
+        type: "Text",
+        props: { content: "hello" },
+        parentKey: "root",
+      },
+    ];
+    const spec = flatToTree(elements as any);
+    expect(spec.root).toBe("root");
+    expect(spec.elements["root"]?.children).toContain("child1");
+    expect(spec.elements["child1"]).toBeDefined();
+  });
+
+  it("handles a single root element with no children", () => {
+    const elements = [
+      { key: "root", type: "Text", props: {}, parentKey: undefined },
+    ];
+    const spec = flatToTree(elements as any);
+    expect(spec.root).toBe("root");
+    expect(spec.elements["root"]?.children).toEqual([]);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// buildSpecFromParts
+// ---------------------------------------------------------------------------
+
+describe("buildSpecFromParts", () => {
+  it("returns null when no spec parts present", () => {
+    const parts: DataPart[] = [{ type: "text", text: "hello" }];
+    expect(buildSpecFromParts(parts)).toBeNull();
+  });
+
+  it("returns null for empty array", () => {
+    expect(buildSpecFromParts([])).toBeNull();
+  });
+
+  it("returns a Spec when a snapshot spec data part is present", () => {
+    const part: DataPart = {
+      type: SPEC_DATA_PART_TYPE,
+      data: {
+        type: "flat",
+        spec: {
+          root: "r",
+          elements: { r: { type: "Text", props: { content: "hi" } } },
+        },
+      },
+    };
+    const result = buildSpecFromParts([part]);
+    expect(result?.root).toBe("r");
+    expect(result?.elements["r"]).toBeDefined();
+  });
+
+  it("applies patch operations incrementally", () => {
+    const parts: DataPart[] = [
+      {
+        type: SPEC_DATA_PART_TYPE,
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "myRoot" },
+        },
+      },
+    ];
+    const result = buildSpecFromParts(parts);
+    expect(result?.root).toBe("myRoot");
+  });
+
+  it("skips malformed data parts silently", () => {
+    const parts: DataPart[] = [
+      { type: SPEC_DATA_PART_TYPE, data: { type: "unknown" } },
+    ];
+    expect(buildSpecFromParts(parts)).toBeNull();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// getTextFromParts
+// ---------------------------------------------------------------------------
+
+describe("getTextFromParts", () => {
+  it("concatenates text parts with double newlines", () => {
+    const parts: DataPart[] = [
+      { type: "text", text: "hello" },
+      { type: "text", text: "world" },
+    ];
+    expect(getTextFromParts(parts)).toBe("hello\n\nworld");
+  });
+
+  it("ignores non-text parts", () => {
+    const parts: DataPart[] = [
+      { type: "other", data: {} },
+      { type: "text", text: "hi" },
+    ];
+    expect(getTextFromParts(parts)).toBe("hi");
+  });
+
+  it("returns empty string for empty array", () => {
+    expect(getTextFromParts([])).toBe("");
+  });
+
+  it("filters out empty/whitespace text parts", () => {
+    const parts: DataPart[] = [
+      { type: "text", text: "  " },
+      { type: "text", text: "real" },
+    ];
+    expect(getTextFromParts(parts)).toBe("real");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// useBoundProp
+// ---------------------------------------------------------------------------
+
+describe("useBoundProp", () => {
+  it("returns the prop value and a no-op setter when no binding path", () => {
+    const { result } = withStateProvider(() =>
+      useBoundProp("hello", undefined),
+    );
+    const [value, setValue] = result;
+    expect(value).toBe("hello");
+    expect(() => setValue("new")).not.toThrow();
+  });
+
+  it("returns undefined prop value when propValue is undefined", () => {
+    const { result } = withStateProvider(() =>
+      useBoundProp<string>(undefined, undefined),
+    );
+    expect(result[0]).toBeUndefined();
+  });
+
+  it("setter writes to state at the binding path", () => {
+    let storeCtx!: ReturnType<typeof useStateStore>;
+    let setter!: (v: string) => void;
+
+    const Child = defineComponent({
+      setup() {
+        storeCtx = useStateStore();
+        const [, s] = useBoundProp<string>("Alice", "/name");
+        setter = s;
+        return () => h("div");
+      },
+    });
+
+    mount(StateProvider as Component, {
+      props: { initialState: { name: "Alice" } } as any,
+      slots: { default: () => h(Child) },
+    });
+
+    setter("Bob");
+    expect(storeCtx.get("/name")).toBe("Bob");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// useUIStream
+// ---------------------------------------------------------------------------
+
+describe("useUIStream", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("send() sets isStreaming true then false after completion", async () => {
+    const patchLine =
+      JSON.stringify({ op: "add", path: "/root", value: "myRoot" }) +
+      "\n" +
+      JSON.stringify({
+        op: "add",
+        path: "/elements/myRoot",
+        value: { type: "Text", props: {} },
+      }) +
+      "\n";
+
+    vi.stubGlobal(
+      "fetch",
+      vi.fn().mockResolvedValue({
+        ok: true,
+        body: makeReadableStream(patchLine),
+      }),
+    );
+
+    let streamResult!: ReturnType<typeof useUIStream>;
+    const Child = defineComponent({
+      setup() {
+        streamResult = useUIStream({ api: "/api/ui" });
+        return () => h("div");
+      },
+    });
+    mount(StateProvider as Component, {
+      props: { initialState: {} } as any,
+      slots: { default: () => h(Child) },
+    });
+
+    expect(streamResult.isStreaming.value).toBe(false);
+    const promise = streamResult.send("build me a UI");
+    expect(streamResult.isStreaming.value).toBe(true);
+    await promise;
+    expect(streamResult.isStreaming.value).toBe(false);
+    expect(streamResult.spec.value?.root).toBe("myRoot");
+  });
+
+  it("error is set when fetch fails", async () => {
+    vi.stubGlobal(
+      "fetch",
+      vi.fn().mockRejectedValue(new Error("Network error")),
+    );
+
+    let streamResult!: ReturnType<typeof useUIStream>;
+    const Child = defineComponent({
+      setup() {
+        streamResult = useUIStream({ api: "/api/ui" });
+        return () => h("div");
+      },
+    });
+    mount(StateProvider as Component, {
+      props: { initialState: {} } as any,
+      slots: { default: () => h(Child) },
+    });
+
+    await streamResult.send("fail");
+    expect(streamResult.error.value?.message).toBe("Network error");
+  });
+
+  it("clear() resets spec and error", async () => {
+    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("oops")));
+
+    let streamResult!: ReturnType<typeof useUIStream>;
+    const Child = defineComponent({
+      setup() {
+        streamResult = useUIStream({ api: "/api/ui" });
+        return () => h("div");
+      },
+    });
+    mount(StateProvider as Component, {
+      props: { initialState: {} } as any,
+      slots: { default: () => h(Child) },
+    });
+
+    await streamResult.send("fail");
+    expect(streamResult.error.value).not.toBeNull();
+    streamResult.clear();
+    expect(streamResult.error.value).toBeNull();
+    expect(streamResult.spec.value).toBeNull();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// useJsonRenderMessage
+// ---------------------------------------------------------------------------
+
+describe("useJsonRenderMessage", () => {
+  it("returns null spec and false hasSpec when no spec parts", () => {
+    const { spec, hasSpec } = useJsonRenderMessage([
+      { type: "text", text: "hi" },
+    ]);
+    expect(spec.value).toBeNull();
+    expect(hasSpec.value).toBe(false);
+  });
+
+  it("extracts text from text parts", () => {
+    const { text } = useJsonRenderMessage([
+      { type: "text", text: "hello" },
+      { type: "text", text: "world" },
+    ]);
+    expect(text.value).toBe("hello\n\nworld");
+  });
+
+  it("is reactive when passed a Ref<DataPart[]>", () => {
+    const parts = ref<DataPart[]>([]);
+    const { text, spec, hasSpec } = useJsonRenderMessage(parts);
+    expect(text.value).toBe("");
+    expect(spec.value).toBeNull();
+
+    parts.value = [{ type: "text", text: "hello" }];
+    expect(text.value).toBe("hello");
+
+    parts.value = [
+      ...parts.value,
+      {
+        type: SPEC_DATA_PART_TYPE,
+        data: {
+          type: "flat",
+          spec: { root: "r", elements: { r: { type: "Text", props: {} } } },
+        },
+      },
+    ];
+    expect(spec.value?.root).toBe("r");
+    expect(hasSpec.value).toBe(true);
+  });
+});

+ 834 - 0
packages/vue/src/hooks.ts

@@ -0,0 +1,834 @@
+import {
+  ref,
+  shallowRef,
+  computed,
+  onUnmounted,
+  isRef,
+  type Ref,
+  type ComputedRef,
+} from "vue";
+import { useStateStore } from "./composables/state";
+import type {
+  Spec,
+  UIElement,
+  FlatElement,
+  JsonPatch,
+  SpecDataPart,
+} from "@json-render/core";
+import {
+  setByPath,
+  getByPath,
+  addByPath,
+  removeByPath,
+  createMixedStreamParser,
+  applySpecPatch,
+  nestedToFlat,
+  SPEC_DATA_PART_TYPE,
+} from "@json-render/core";
+
+/**
+ * Token usage metadata from AI generation
+ */
+export interface TokenUsage {
+  promptTokens: number;
+  completionTokens: number;
+  totalTokens: number;
+}
+
+/**
+ * Parse result for a single line -- either a patch or usage metadata
+ */
+type ParsedLine =
+  | { type: "patch"; patch: JsonPatch }
+  | { type: "usage"; usage: TokenUsage }
+  | null;
+
+/**
+ * Parse a single JSON line (patch or metadata)
+ */
+function parseLine(line: string): ParsedLine {
+  try {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("//")) {
+      return null;
+    }
+    const parsed = JSON.parse(trimmed);
+
+    // Check for usage metadata
+    if (parsed.__meta === "usage") {
+      return {
+        type: "usage",
+        usage: {
+          promptTokens: parsed.promptTokens ?? 0,
+          completionTokens: parsed.completionTokens ?? 0,
+          totalTokens: parsed.totalTokens ?? 0,
+        },
+      };
+    }
+
+    return { type: "patch", patch: parsed as JsonPatch };
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Set a value at a spec path (for add/replace operations).
+ */
+function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
+  if (path === "/root") {
+    newSpec.root = value as string;
+    return;
+  }
+
+  if (path === "/state") {
+    newSpec.state = value as Record<string, unknown>;
+    return;
+  }
+
+  if (path.startsWith("/state/")) {
+    if (!newSpec.state) newSpec.state = {};
+    const statePath = path.slice("/state".length); // e.g. "/posts"
+    setByPath(newSpec.state as Record<string, unknown>, statePath, value);
+    return;
+  }
+
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      newSpec.elements[elementKey] = value as UIElement;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        setByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+          value,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Remove a value at a spec path.
+ */
+function removeSpecValue(newSpec: Spec, path: string): void {
+  if (path === "/state") {
+    delete newSpec.state;
+    return;
+  }
+
+  if (path.startsWith("/state/") && newSpec.state) {
+    const statePath = path.slice("/state".length);
+    removeByPath(newSpec.state as Record<string, unknown>, statePath);
+    return;
+  }
+
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      const { [elementKey]: _, ...rest } = newSpec.elements;
+      newSpec.elements = rest;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        removeByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Get a value at a spec path.
+ */
+function getSpecValue(spec: Spec, path: string): unknown {
+  if (path === "/root") return spec.root;
+  if (path === "/state") return spec.state;
+  if (path.startsWith("/state/") && spec.state) {
+    const statePath = path.slice("/state".length);
+    return getByPath(spec.state as Record<string, unknown>, statePath);
+  }
+  return getByPath(spec as unknown as Record<string, unknown>, path);
+}
+
+/**
+ * Apply an RFC 6902 JSON patch to the current spec.
+ * Supports add, remove, replace, move, copy, and test operations.
+ */
+function applyPatch(spec: Spec, patch: JsonPatch): Spec {
+  const newSpec = {
+    ...spec,
+    elements: { ...spec.elements },
+    ...(spec.state ? { state: { ...spec.state } } : {}),
+  };
+
+  switch (patch.op) {
+    case "add":
+    case "replace": {
+      setSpecValue(newSpec, patch.path, patch.value);
+      break;
+    }
+    case "remove": {
+      removeSpecValue(newSpec, patch.path);
+      break;
+    }
+    case "move": {
+      if (!patch.from) break;
+      const moveValue = getSpecValue(newSpec, patch.from);
+      removeSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, moveValue);
+      break;
+    }
+    case "copy": {
+      if (!patch.from) break;
+      const copyValue = getSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, copyValue);
+      break;
+    }
+    case "test": {
+      // test is a no-op for rendering purposes (validation only)
+      break;
+    }
+  }
+
+  return newSpec;
+}
+
+/**
+ * Options for useUIStream
+ */
+export interface UseUIStreamOptions {
+  /** API endpoint */
+  api: string;
+  /** Callback when complete */
+  onComplete?: (spec: Spec) => void;
+  /** Callback on error */
+  onError?: (error: Error) => void;
+}
+
+/**
+ * Return type for useUIStream
+ */
+export interface UseUIStreamReturn {
+  /** Current UI spec */
+  spec: Ref<Spec | null>;
+  /** Whether currently streaming */
+  isStreaming: Ref<boolean>;
+  /** Error if any */
+  error: Ref<Error | null>;
+  /** Token usage from the last generation */
+  usage: Ref<TokenUsage | null>;
+  /** Raw JSONL lines received from the stream (JSON patch lines) */
+  rawLines: Ref<string[]>;
+  /** Send a prompt to generate UI */
+  send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
+  /** Clear the current spec */
+  clear: () => void;
+}
+
+/**
+ * Composable for streaming UI generation
+ */
+export function useUIStream({
+  api,
+  onComplete,
+  onError,
+}: UseUIStreamOptions): UseUIStreamReturn {
+  const spec = shallowRef<Spec | null>(null);
+  const isStreaming = ref(false);
+  const error = ref<Error | null>(null);
+  const usage = ref<TokenUsage | null>(null);
+  const rawLines = ref<string[]>([]);
+
+  // Keep latest callbacks (Vue refs are always current — no stale closure issues)
+  const onCompleteRef = ref(onComplete);
+  onCompleteRef.value = onComplete;
+  const onErrorRef = ref(onError);
+  onErrorRef.value = onError;
+
+  let abortController: AbortController | null = null;
+
+  const clear = () => {
+    spec.value = null;
+    error.value = null;
+  };
+
+  const send = async (
+    prompt: string,
+    context?: Record<string, unknown>,
+  ): Promise<void> => {
+    // Abort any existing request
+    abortController?.abort();
+    abortController = new AbortController();
+
+    isStreaming.value = true;
+    error.value = null;
+    usage.value = null;
+    rawLines.value = [];
+
+    // Start with previous spec if provided, otherwise empty spec
+    const previousSpec = context?.previousSpec as Spec | undefined;
+    let currentSpec: Spec =
+      previousSpec && previousSpec.root
+        ? { ...previousSpec, elements: { ...previousSpec.elements } }
+        : { root: "", elements: {} };
+    spec.value = currentSpec;
+
+    try {
+      const response = await fetch(api, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          prompt,
+          context,
+          currentSpec,
+        }),
+        signal: abortController.signal,
+      });
+
+      if (!response.ok) {
+        // Try to parse JSON error response for better error messages
+        let errorMessage = `HTTP error: ${response.status}`;
+        try {
+          const errorData = await response.json();
+          if (errorData.message) {
+            errorMessage = errorData.message;
+          } else if (errorData.error) {
+            errorMessage = errorData.error;
+          }
+        } catch {
+          // Ignore JSON parsing errors, use default message
+        }
+        throw new Error(errorMessage);
+      }
+
+      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 trimmed = line.trim();
+          if (!trimmed) continue;
+          const result = parseLine(trimmed);
+          if (!result) continue;
+          if (result.type === "usage") {
+            usage.value = result.usage;
+          } else {
+            rawLines.value = [...rawLines.value, trimmed];
+            currentSpec = applyPatch(currentSpec, result.patch);
+            spec.value = { ...currentSpec };
+          }
+        }
+      }
+
+      // Process any remaining buffer
+      if (buffer.trim()) {
+        const trimmed = buffer.trim();
+        const result = parseLine(trimmed);
+        if (result) {
+          if (result.type === "usage") {
+            usage.value = result.usage;
+          } else {
+            rawLines.value = [...rawLines.value, trimmed];
+            currentSpec = applyPatch(currentSpec, result.patch);
+            spec.value = { ...currentSpec };
+          }
+        }
+      }
+
+      onCompleteRef.value?.(currentSpec);
+    } catch (err) {
+      if ((err as Error).name === "AbortError") {
+        return;
+      }
+      const resolvedError = err instanceof Error ? err : new Error(String(err));
+      error.value = resolvedError;
+      onErrorRef.value?.(resolvedError);
+    } finally {
+      isStreaming.value = false;
+    }
+  };
+
+  // Cleanup on unmount
+  onUnmounted(() => {
+    abortController?.abort();
+  });
+
+  return {
+    spec,
+    isStreaming,
+    error,
+    usage,
+    rawLines,
+    send,
+    clear,
+  };
+}
+
+/**
+ * Convert a flat element list to a Spec.
+ * Input elements use key/parentKey to establish identity and relationships.
+ * Output spec uses the map-based format where key is the map entry key
+ * and parent-child relationships are expressed through children arrays.
+ */
+export function flatToTree(elements: FlatElement[]): Spec {
+  const elementMap: Record<string, UIElement> = {};
+  let root = "";
+
+  // First pass: add all elements to map
+  for (const element of elements) {
+    elementMap[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 };
+}
+
+// =============================================================================
+// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
+// =============================================================================
+
+/**
+ * Composable for two-way bound props. Returns `[value, setValue]` where:
+ *
+ * - `value` is the already-resolved prop value (passed through from render props)
+ * - `setValue` writes back to the bound state path (no-op if not bound)
+ *
+ * Designed to work with the `bindings` map that the renderer provides when
+ * a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
+ *
+ * @example
+ * ```ts
+ * import { useBoundProp } from "@json-render/vue";
+ *
+ * const Input: ComponentFn<AppCatalog, "Input"> = ({ props, bindings }) => {
+ *   const [value, setValue] = useBoundProp<string>(props.value as string, bindings?.value);
+ *   return h("input", { value: value ?? "", onInput: (e) => setValue(e.target.value) });
+ * };
+ * ```
+ */
+export function useBoundProp<T>(
+  propValue: T | undefined,
+  bindingPath: string | undefined,
+): [T | undefined, (value: T) => void] {
+  const { set } = useStateStore();
+  return [
+    propValue,
+    (value: T) => {
+      if (bindingPath) set(bindingPath, value);
+    },
+  ];
+}
+
+// =============================================================================
+// buildSpecFromParts — Derive Spec from AI SDK data parts
+// =============================================================================
+
+/**
+ * A single part from the AI SDK's `message.parts` array. This is a minimal
+ * structural type so that library helpers do not depend on the AI SDK.
+ * Fields are optional because different part types carry different data:
+ * - Text parts have `text`
+ * - Data parts have `data`
+ */
+export interface DataPart {
+  type: string;
+  text?: string;
+  data?: unknown;
+}
+
+/**
+ * Type guard that validates a data part payload looks like a valid
+ * SpecDataPart before we cast it.
+ */
+function isSpecDataPart(data: unknown): data is SpecDataPart {
+  if (typeof data !== "object" || data === null) return false;
+  const obj = data as Record<string, unknown>;
+  switch (obj.type) {
+    case "patch":
+      return typeof obj.patch === "object" && obj.patch !== null;
+    case "flat":
+    case "nested":
+      return typeof obj.spec === "object" && obj.spec !== null;
+    default:
+      return false;
+  }
+}
+
+/**
+ * Build a `Spec` by replaying all spec data parts from a message's
+ * parts array. Returns `null` if no spec data parts are present.
+ *
+ * Works with the AI SDK's `UIMessage.parts` array. Picks out parts whose
+ * `type` is `SPEC_DATA_PART_TYPE` and processes them based on the payload's
+ * `type` discriminator: `"patch"`, `"flat"`, or `"nested"`.
+ */
+export function buildSpecFromParts(parts: DataPart[]): Spec | null {
+  const spec: Spec = { root: "", elements: {} };
+  let hasSpec = false;
+
+  for (const part of parts) {
+    if (part.type === SPEC_DATA_PART_TYPE) {
+      if (!isSpecDataPart(part.data)) continue;
+      const payload = part.data;
+      if (payload.type === "patch") {
+        hasSpec = true;
+        applySpecPatch(spec, payload.patch);
+      } else if (payload.type === "flat") {
+        hasSpec = true;
+        Object.assign(spec, payload.spec);
+      } else if (payload.type === "nested") {
+        hasSpec = true;
+        const flat = nestedToFlat(payload.spec);
+        Object.assign(spec, flat);
+      }
+    }
+  }
+
+  return hasSpec ? spec : null;
+}
+
+/**
+ * Extract and join all text content from a message's parts array.
+ *
+ * Filters for parts with `type === "text"`, trims each one, and joins them
+ * with double newlines so that text from separate agent steps renders as
+ * distinct paragraphs in markdown.
+ */
+export function getTextFromParts(parts: DataPart[]): string {
+  return parts
+    .filter(
+      (p): p is DataPart & { text: string } =>
+        p.type === "text" && typeof p.text === "string",
+    )
+    .map((p) => p.text.trim())
+    .filter(Boolean)
+    .join("\n\n");
+}
+
+// =============================================================================
+// useJsonRenderMessage — extract spec + text from message parts
+// =============================================================================
+
+/**
+ * Composable that extracts both the json-render spec and text content from a
+ * message's parts array. Accepts a plain `DataPart[]` or a `Ref<DataPart[]>`
+ * for reactive use in streaming scenarios.
+ *
+ * Returns `ComputedRef`s that recompute whenever `parts` changes.
+ *
+ * @example
+ * ```ts
+ * import { useJsonRenderMessage } from "@json-render/vue";
+ *
+ * const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
+ * ```
+ */
+export function useJsonRenderMessage(parts: DataPart[] | Ref<DataPart[]>): {
+  spec: ComputedRef<Spec | null>;
+  text: ComputedRef<string>;
+  hasSpec: ComputedRef<boolean>;
+} {
+  const partsRef = isRef(parts) ? parts : ref(parts);
+  const spec = computed(() => buildSpecFromParts(partsRef.value));
+  const text = computed(() => getTextFromParts(partsRef.value));
+  const hasSpec = computed(
+    () =>
+      spec.value !== null && Object.keys(spec.value.elements || {}).length > 0,
+  );
+  return { spec, text, hasSpec };
+}
+
+// =============================================================================
+// useChatUI — Chat + GenUI composable
+// =============================================================================
+
+/**
+ * A single message in the chat, which may contain text, a rendered UI spec, or both.
+ */
+export interface ChatMessage {
+  /** Unique message ID */
+  id: string;
+  /** Who sent this message */
+  role: "user" | "assistant";
+  /** Text content (conversational prose) */
+  text: string;
+  /** json-render Spec built from JSONL patches (null if no UI was generated) */
+  spec: Spec | null;
+}
+
+/**
+ * Options for useChatUI
+ */
+export interface UseChatUIOptions {
+  /** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
+  api: string;
+  /** Callback when streaming completes for a message */
+  onComplete?: (message: ChatMessage) => void;
+  /** Callback on error */
+  onError?: (error: Error) => void;
+}
+
+/**
+ * Return type for useChatUI
+ */
+export interface UseChatUIReturn {
+  /** All messages in the conversation */
+  messages: Ref<ChatMessage[]>;
+  /** Whether currently streaming an assistant response */
+  isStreaming: Ref<boolean>;
+  /** Error from the last request, if any */
+  error: Ref<Error | null>;
+  /** Send a user message */
+  send: (text: string) => Promise<void>;
+  /** Clear all messages and reset the conversation */
+  clear: () => void;
+}
+
+let chatMessageIdCounter = 0;
+function generateChatId(): string {
+  if (
+    typeof crypto !== "undefined" &&
+    typeof crypto.randomUUID === "function"
+  ) {
+    return crypto.randomUUID();
+  }
+  chatMessageIdCounter += 1;
+  return `msg-${Date.now()}-${chatMessageIdCounter}`;
+}
+
+/**
+ * Composable for chat + GenUI experiences.
+ *
+ * Manages a multi-turn conversation where each assistant message can contain
+ * both conversational text and a json-render UI spec. Sends the full message
+ * history to the API endpoint, reads the streamed response, and separates
+ * text lines from JSONL patch lines using `createMixedStreamParser`.
+ *
+ * @example
+ * ```ts
+ * const { messages, isStreaming, send, clear } = useChatUI({ api: "/api/chat" });
+ *
+ * await send("Compare weather in NYC and Tokyo");
+ * ```
+ */
+export function useChatUI({
+  api,
+  onComplete,
+  onError,
+}: UseChatUIOptions): UseChatUIReturn {
+  const messages = ref<ChatMessage[]>([]);
+  const isStreaming = ref(false);
+  const error = ref<Error | null>(null);
+
+  // Keep latest callbacks (Vue refs are always current — no stale closure issues)
+  const onCompleteRef = ref(onComplete);
+  onCompleteRef.value = onComplete;
+  const onErrorRef = ref(onError);
+  onErrorRef.value = onError;
+
+  let abortController: AbortController | null = null;
+
+  const clear = () => {
+    messages.value = [];
+    error.value = null;
+  };
+
+  const send = async (text: string): Promise<void> => {
+    if (!text.trim()) return;
+
+    // Abort any existing request
+    abortController?.abort();
+    abortController = new AbortController();
+
+    const userMessage: ChatMessage = {
+      id: generateChatId(),
+      role: "user",
+      text: text.trim(),
+      spec: null,
+    };
+
+    const assistantId = generateChatId();
+    const assistantMessage: ChatMessage = {
+      id: assistantId,
+      role: "assistant",
+      text: "",
+      spec: null,
+    };
+
+    // Append user message and empty assistant placeholder
+    messages.value = [...messages.value, userMessage, assistantMessage];
+    isStreaming.value = true;
+    error.value = null;
+
+    // Build messages array for the API (full conversation history + new message).
+    // Vue refs are always current — no stale closure issue unlike React useRef.
+    const historyForApi = [
+      ...messages.value
+        .filter((m) => m.id !== assistantId)
+        .map((m) => ({ role: m.role, content: m.text })),
+      { role: "user" as const, content: text.trim() },
+    ];
+
+    // Mutable state for accumulating the assistant response
+    let accumulatedText = "";
+    let currentSpec: Spec = { root: "", elements: {} };
+    let hasSpec = false;
+
+    try {
+      const response = await fetch(api, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ messages: historyForApi }),
+        signal: abortController.signal,
+      });
+
+      if (!response.ok) {
+        let errorMessage = `HTTP error: ${response.status}`;
+        try {
+          const errorData = await response.json();
+          if (errorData.message) {
+            errorMessage = errorData.message;
+          } else if (errorData.error) {
+            errorMessage = errorData.error;
+          }
+        } catch {
+          // Ignore JSON parsing errors
+        }
+        throw new Error(errorMessage);
+      }
+
+      const reader = response.body?.getReader();
+      if (!reader) {
+        throw new Error("No response body");
+      }
+
+      const decoder = new TextDecoder();
+
+      // Use createMixedStreamParser to classify lines
+      const parser = createMixedStreamParser({
+        onPatch(patch) {
+          hasSpec = true;
+          applySpecPatch(currentSpec, patch);
+          messages.value = messages.value.map((m) =>
+            m.id === assistantId
+              ? {
+                  ...m,
+                  spec: {
+                    root: currentSpec.root,
+                    elements: { ...currentSpec.elements },
+                    ...(currentSpec.state
+                      ? { state: { ...currentSpec.state } }
+                      : {}),
+                  },
+                }
+              : m,
+          );
+        },
+        onText(line) {
+          accumulatedText += (accumulatedText ? "\n" : "") + line;
+          messages.value = messages.value.map((m) =>
+            m.id === assistantId ? { ...m, text: accumulatedText } : m,
+          );
+        },
+      });
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        parser.push(decoder.decode(value, { stream: true }));
+      }
+      parser.flush();
+
+      // Build final message for onComplete callback
+      const finalMessage: ChatMessage = {
+        id: assistantId,
+        role: "assistant",
+        text: accumulatedText,
+        spec: hasSpec
+          ? {
+              root: currentSpec.root,
+              elements: { ...currentSpec.elements },
+              ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
+            }
+          : null,
+      };
+      onCompleteRef.value?.(finalMessage);
+    } catch (err) {
+      if ((err as Error).name === "AbortError") {
+        return;
+      }
+      const resolvedError = err instanceof Error ? err : new Error(String(err));
+      error.value = resolvedError;
+      // Remove empty assistant message on error
+      messages.value = messages.value.filter(
+        (m) => m.id !== assistantId || m.text.length > 0,
+      );
+      onErrorRef.value?.(resolvedError);
+    } finally {
+      isStreaming.value = false;
+    }
+  };
+
+  // Cleanup on unmount
+  onUnmounted(() => {
+    abortController?.abort();
+  });
+
+  return {
+    messages,
+    isStreaming,
+    error,
+    send,
+    clear,
+  };
+}

+ 99 - 0
packages/vue/src/index.ts

@@ -0,0 +1,99 @@
+// Composables & Providers
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "./composables/state";
+
+export {
+  VisibilityProvider,
+  useVisibility,
+  useIsVisible,
+  type VisibilityContextValue,
+} from "./composables/visibility";
+
+export {
+  ActionProvider,
+  useActions,
+  useAction,
+  ConfirmDialog,
+  type ActionContextValue,
+  type ActionProviderProps,
+  type PendingConfirmation,
+  type ConfirmDialogProps,
+} from "./composables/actions";
+
+export {
+  ValidationProvider,
+  useOptionalValidation,
+  useValidation,
+  useFieldValidation,
+  type ValidationContextValue,
+  type ValidationProviderProps,
+  type FieldValidationState,
+} from "./composables/validation";
+
+export {
+  RepeatScopeProvider,
+  useRepeatScope,
+  type RepeatScopeValue,
+} from "./composables/repeat-scope";
+
+// Schema
+export { schema, type VueSchema, type VueSpec } from "./schema";
+
+// Core types (re-exported for convenience)
+export type { Spec, StateStore } from "@json-render/core";
+export { createStateStore } from "@json-render/core";
+
+// Catalog-aware types for Vue
+export type {
+  EventHandle,
+  BaseComponentProps,
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+  ActionFn,
+  Actions,
+} from "./catalog-types";
+
+// Hooks
+export {
+  useUIStream,
+  useChatUI,
+  useBoundProp,
+  flatToTree,
+  buildSpecFromParts,
+  getTextFromParts,
+  useJsonRenderMessage,
+  type UseUIStreamOptions,
+  type UseUIStreamReturn,
+  type UseChatUIOptions,
+  type UseChatUIReturn,
+  type ChatMessage,
+  type DataPart,
+  type TokenUsage,
+} from "./hooks";
+
+// Renderer
+export {
+  // Registry
+  defineRegistry,
+  type DefineRegistryResult,
+  // createRenderer (higher-level, includes providers)
+  createRenderer,
+  type CreateRendererProps,
+  type ComponentMap,
+  // Low-level
+  Renderer,
+  JSONUIProvider,
+  type ComponentRenderProps,
+  type ComponentRegistry,
+  type RendererProps,
+  type JSONUIProviderProps,
+} from "./renderer";

+ 200 - 0
packages/vue/src/renderer.test.ts

@@ -0,0 +1,200 @@
+import { describe, it, expect, vi } from "vitest";
+import { defineComponent, h, type Component } from "vue";
+import { mount } from "@vue/test-utils";
+import type { Spec } from "@json-render/core";
+import { StateProvider } from "./composables/state";
+import { VisibilityProvider } from "./composables/visibility";
+import { ActionProvider } from "./composables/actions";
+import { ValidationProvider } from "./composables/validation";
+import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
+
+// ---------------------------------------------------------------------------
+// Minimal test catalog and registry
+// ---------------------------------------------------------------------------
+
+// defineRegistry ignores the catalog object at runtime — use any cast
+const catalog = {} as any;
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => {
+      const p = props as Record<string, unknown>;
+      return h(
+        "div",
+        { "data-type": "card", "data-title": String(p["title"] ?? "") },
+        [String(p["title"] ?? ""), children],
+      );
+    },
+    Button: ({ props, emit }) => {
+      const p = props as Record<string, unknown>;
+      return h(
+        "button",
+        { "data-type": "button", onClick: () => emit("press") },
+        String(p["label"] ?? ""),
+      );
+    },
+  },
+});
+
+// ---------------------------------------------------------------------------
+// Mount helper: wraps in the full provider chain required by ElementRenderer
+// ---------------------------------------------------------------------------
+
+function mountRenderer(
+  spec: Spec | null,
+  reg: ComponentRegistry = registry,
+  extraProps: Record<string, unknown> = {},
+  handlers: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<void>
+  > = {},
+  initialState: Record<string, unknown> = {},
+) {
+  return mount(StateProvider as Component, {
+    props: { initialState } as any,
+    slots: {
+      default: () =>
+        h(VisibilityProvider as Component, null, {
+          default: () =>
+            h(ActionProvider as Component, { handlers } as any, {
+              default: () =>
+                h(ValidationProvider as Component, null, {
+                  default: () =>
+                    h(Renderer, { spec, registry: reg, ...extraProps }),
+                }),
+            }),
+        }),
+    },
+  });
+}
+
+// ---------------------------------------------------------------------------
+// defineRegistry tests
+// ---------------------------------------------------------------------------
+
+describe("defineRegistry", () => {
+  it("wraps a render function and returns a Vue component", () => {
+    const result = defineRegistry(catalog, {
+      components: {
+        MyComp: () => h("span", null, "hello"),
+      },
+    });
+    expect(result.registry).toBeDefined();
+    expect(result.registry["MyComp"]).toBeDefined();
+  });
+
+  it("component receives resolved props from the spec element", () => {
+    const spec: Spec = {
+      root: "btn1",
+      elements: {
+        btn1: { type: "Button", props: { label: "Press me" } },
+      },
+    };
+    const wrapper = mountRenderer(spec);
+    expect(wrapper.find("[data-type='button']").text()).toBe("Press me");
+  });
+
+  it("children is passed for container components", () => {
+    const spec: Spec = {
+      root: "card1",
+      elements: {
+        card1: {
+          type: "Card",
+          props: { title: "My Card" },
+          children: ["btn1"],
+        },
+        btn1: { type: "Button", props: { label: "Click" } },
+      },
+    };
+    const wrapper = mountRenderer(spec);
+    expect(wrapper.find("[data-type='card']").exists()).toBe(true);
+    expect(wrapper.find("[data-type='button']").exists()).toBe(true);
+  });
+
+  it("emit('press') fires the corresponding on.press action", async () => {
+    const handler = vi.fn().mockResolvedValue(undefined);
+    const spec: Spec = {
+      root: "btn1",
+      elements: {
+        btn1: {
+          type: "Button",
+          props: { label: "Click" },
+          on: { press: { action: "myAction" } },
+        },
+      },
+    };
+    const wrapper = mountRenderer(spec, registry, {}, { myAction: handler });
+    await wrapper.find("[data-type='button']").trigger("click");
+    expect(handler).toHaveBeenCalledOnce();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Renderer tests
+// ---------------------------------------------------------------------------
+
+describe("Renderer", () => {
+  it("renders a single-element spec", () => {
+    const spec: Spec = {
+      root: "btn1",
+      elements: {
+        btn1: { type: "Button", props: { label: "Go" } },
+      },
+    };
+    const wrapper = mountRenderer(spec);
+    expect(wrapper.find("[data-type='button']").exists()).toBe(true);
+    expect(wrapper.find("[data-type='button']").text()).toBe("Go");
+  });
+
+  it("renders a nested spec (parent contains a child by key reference)", () => {
+    const spec: Spec = {
+      root: "card1",
+      elements: {
+        card1: {
+          type: "Card",
+          props: { title: "Root" },
+          children: ["btn1"],
+        },
+        btn1: { type: "Button", props: { label: "Action" } },
+      },
+    };
+    const wrapper = mountRenderer(spec);
+    const card = wrapper.find("[data-type='card']");
+    expect(card.exists()).toBe(true);
+    expect(card.find("[data-type='button']").exists()).toBe(true);
+  });
+
+  it("uses fallback component for unknown element types", () => {
+    const fallback = defineComponent({
+      setup() {
+        return () => h("span", { "data-type": "fallback" }, "fallback");
+      },
+    });
+    const spec: Spec = {
+      root: "el1",
+      elements: {
+        el1: { type: "Unknown", props: {} },
+      },
+    };
+    const wrapper = mountRenderer(spec, registry, { fallback });
+    expect(wrapper.find("[data-type='fallback']").exists()).toBe(true);
+  });
+
+  it("passes loading prop through to registered components", () => {
+    let receivedLoading: boolean | undefined;
+    const { registry: testRegistry } = defineRegistry(catalog, {
+      components: {
+        Widget: ({ loading }) => {
+          receivedLoading = loading;
+          return h("div", null, "widget");
+        },
+      },
+    });
+    const spec: Spec = {
+      root: "w1",
+      elements: { w1: { type: "Widget", props: {} } },
+    };
+    mountRenderer(spec, testRegistry, { loading: true });
+    expect(receivedLoading).toBe(true);
+  });
+});

+ 834 - 0
packages/vue/src/renderer.ts

@@ -0,0 +1,834 @@
+import {
+  computed,
+  defineComponent,
+  h,
+  onErrorCaptured,
+  ref,
+  type Component,
+  type PropType,
+  type VNode,
+} from "vue";
+import type {
+  UIElement,
+  Spec,
+  ActionBinding,
+  Catalog,
+  SchemaDefinition,
+  StateStore,
+} from "@json-render/core";
+import {
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+} from "@json-render/core";
+import type {
+  Components,
+  Actions,
+  ActionFn,
+  SetState,
+  StateModel,
+  CatalogHasActions,
+  EventHandle,
+} from "./catalog-types";
+import { useVisibility } from "./composables/visibility";
+import { useActions } from "./composables/actions";
+import { useStateStore } from "./composables/state";
+import { StateProvider } from "./composables/state";
+import { VisibilityProvider } from "./composables/visibility";
+import { ActionProvider } from "./composables/actions";
+import { ValidationProvider } from "./composables/validation";
+import { ConfirmDialog } from "./composables/actions";
+import {
+  RepeatScopeProvider,
+  useRepeatScope,
+} from "./composables/repeat-scope";
+
+/**
+ * Props passed to component renderers
+ */
+export interface ComponentRenderProps<P = Record<string, unknown>> {
+  /** The element being rendered */
+  element: UIElement<string, P>;
+  /** Emit a named event */
+  emit: (event: string) => void;
+  /** Get an event handle with metadata */
+  on: (event: string) => EventHandle;
+  /**
+   * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
+   * Maps prop name → absolute state path for write-back.
+   */
+  bindings?: Record<string, string>;
+  /** Whether the parent is loading */
+  loading?: boolean;
+}
+
+/**
+ * Registry of component renderers (Vue component definitions)
+ */
+export type ComponentRegistry = Record<string, Component>;
+
+/**
+ * Props for the Renderer component
+ */
+export interface RendererProps {
+  spec: Spec | null;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: Component;
+}
+
+// ---------------------------------------------------------------------------
+// ElementErrorBoundary — catches rendering errors in individual elements
+// ---------------------------------------------------------------------------
+
+const ElementErrorBoundary = defineComponent({
+  name: "ElementErrorBoundary",
+  props: {
+    elementType: {
+      type: String,
+      required: true,
+    },
+  },
+  setup(props, { slots }) {
+    const hasError = ref(false);
+
+    onErrorCaptured((error) => {
+      console.error(
+        `[json-render] Rendering error in <${props.elementType}>:`,
+        error,
+      );
+      hasError.value = true;
+      return false; // prevent propagation
+    });
+
+    return () => {
+      if (hasError.value) return null;
+      return slots.default?.();
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// ElementRenderer — renders a single element from the spec
+// ---------------------------------------------------------------------------
+
+interface ElementRendererInternalProps {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: Component;
+}
+
+const ElementRenderer = defineComponent({
+  name: "JsonRenderElement",
+  props: {
+    element: {
+      type: Object as PropType<UIElement>,
+      required: true,
+    },
+    spec: {
+      type: Object as PropType<Spec>,
+      required: true,
+    },
+    registry: {
+      type: Object as PropType<ComponentRegistry>,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      default: undefined,
+    },
+    fallback: {
+      type: Object as PropType<Component>,
+      default: undefined,
+    },
+  },
+  setup(props: ElementRendererInternalProps) {
+    const repeatScope = useRepeatScope();
+    const { ctx: visibilityCtx } = useVisibility();
+    const { execute } = useActions();
+    const { getSnapshot } = useStateStore();
+
+    // Build context with repeat scope
+    const fullCtx = computed<PropResolutionContext>(() => {
+      const base = visibilityCtx.value;
+      if (repeatScope) {
+        return {
+          ...base,
+          repeatItem: repeatScope.item,
+          repeatIndex: repeatScope.index,
+          repeatBasePath: repeatScope.basePath,
+        };
+      }
+      return base;
+    });
+
+    // Create emit function
+    const emitEvent = async (eventName: string): Promise<void> => {
+      const binding = props.element.on?.[eventName];
+      if (!binding) return;
+      const actionBindings = Array.isArray(binding) ? binding : [binding];
+      for (const b of actionBindings) {
+        if (!b.params) {
+          await execute(b);
+          continue;
+        }
+        const liveCtx: PropResolutionContext = {
+          ...fullCtx.value,
+          stateModel: getSnapshot(),
+        };
+        const resolved: Record<string, unknown> = {};
+        for (const [key, val] of Object.entries(b.params)) {
+          resolved[key] = resolveActionParam(val, liveCtx);
+        }
+        await execute({ ...b, params: resolved });
+      }
+    };
+
+    // Create on() function
+    const onEvent = (eventName: string): EventHandle => {
+      const binding = props.element.on?.[eventName];
+      if (!binding) {
+        return { emit: () => {}, shouldPreventDefault: false, bound: false };
+      }
+      const actionBindings = Array.isArray(binding) ? binding : [binding];
+      const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
+      return {
+        emit: () => {
+          void emitEvent(eventName);
+        },
+        shouldPreventDefault,
+        bound: true,
+      };
+    };
+
+    return () => {
+      const ctx = fullCtx.value;
+
+      // Evaluate visibility
+      const isVisible =
+        props.element.visible === undefined
+          ? true
+          : evaluateVisibility(props.element.visible, ctx);
+
+      if (!isVisible) return null;
+
+      // Resolve bindings and props
+      const rawProps = props.element.props as Record<string, unknown>;
+      const elementBindings = resolveBindings(rawProps, ctx);
+      const resolvedProps = resolveElementProps(rawProps, ctx);
+
+      const resolvedElement =
+        resolvedProps !== props.element.props
+          ? { ...props.element, props: resolvedProps }
+          : props.element;
+
+      // Get component from registry
+      const Component = props.registry[resolvedElement.type] ?? props.fallback;
+
+      if (!Component) {
+        console.warn(
+          `[json-render] No renderer for component type: ${resolvedElement.type}`,
+        );
+        return null;
+      }
+
+      // Render children
+      const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat
+        ? h(RepeatChildren, {
+            element: resolvedElement,
+            spec: props.spec,
+            registry: props.registry,
+            loading: props.loading,
+            fallback: props.fallback,
+          })
+        : (resolvedElement.children
+            ?.map((childKey) => {
+              const childElement = props.spec.elements[childKey];
+              if (!childElement) {
+                if (!props.loading) {
+                  console.warn(
+                    `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
+                  );
+                }
+                return null;
+              }
+              return h(ElementRenderer, {
+                key: childKey,
+                element: childElement,
+                spec: props.spec,
+                registry: props.registry,
+                loading: props.loading,
+                fallback: props.fallback,
+              });
+            })
+            .filter((n): n is VNode => n !== null) ?? undefined);
+
+      return h(
+        ElementErrorBoundary,
+        { elementType: resolvedElement.type },
+        {
+          default: () =>
+            h(
+              Component,
+              {
+                element: resolvedElement,
+                emit: emitEvent,
+                on: onEvent,
+                bindings: elementBindings,
+                loading: props.loading,
+              },
+              { default: () => childrenVNodes },
+            ),
+        },
+      );
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// RepeatChildren — renders child elements once per item in a state array
+// ---------------------------------------------------------------------------
+
+const RepeatChildren = defineComponent({
+  name: "JsonRenderRepeatChildren",
+  props: {
+    element: {
+      type: Object as PropType<UIElement>,
+      required: true,
+    },
+    spec: {
+      type: Object as PropType<Spec>,
+      required: true,
+    },
+    registry: {
+      type: Object as PropType<ComponentRegistry>,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      default: undefined,
+    },
+    fallback: {
+      type: Object as PropType<Component>,
+      default: undefined,
+    },
+  },
+  setup(props) {
+    const { state } = useStateStore();
+
+    return () => {
+      const repeat = props.element.repeat!;
+      const statePath = repeat.statePath;
+      const items =
+        (getByPath(state.value, statePath) as unknown[] | undefined) ?? [];
+
+      return items.map((itemValue, index) => {
+        const key =
+          repeat.key && typeof itemValue === "object" && itemValue !== null
+            ? String(
+                (itemValue as Record<string, unknown>)[repeat.key] ?? index,
+              )
+            : String(index);
+
+        return h(
+          RepeatScopeProvider,
+          { key, item: itemValue, index, basePath: `${statePath}/${index}` },
+          {
+            default: () =>
+              props.element.children
+                ?.map((childKey) => {
+                  const childElement = props.spec.elements[childKey];
+                  if (!childElement) {
+                    if (!props.loading) {
+                      console.warn(
+                        `[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`,
+                      );
+                    }
+                    return null;
+                  }
+                  return h(ElementRenderer, {
+                    key: childKey,
+                    element: childElement,
+                    spec: props.spec,
+                    registry: props.registry,
+                    loading: props.loading,
+                    fallback: props.fallback,
+                  });
+                })
+                .filter((n): n is VNode => n !== null) ?? null,
+          },
+        );
+      });
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// Renderer — main exported component
+// ---------------------------------------------------------------------------
+
+/**
+ * Main renderer component
+ */
+export const Renderer = defineComponent({
+  name: "JsonRenderer",
+  props: {
+    spec: {
+      type: Object as PropType<Spec | null>,
+      default: null,
+    },
+    registry: {
+      type: Object as PropType<ComponentRegistry>,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      default: undefined,
+    },
+    fallback: {
+      type: Object as PropType<Component>,
+      default: undefined,
+    },
+  },
+  setup(props) {
+    return () => {
+      if (!props.spec?.root) return null;
+
+      const rootElement = props.spec.elements[props.spec.root];
+      if (!rootElement) return null;
+
+      return h(ElementRenderer, {
+        element: rootElement,
+        spec: props.spec,
+        registry: props.registry,
+        loading: props.loading,
+        fallback: props.fallback,
+      });
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// ConfirmationDialogManager
+// ---------------------------------------------------------------------------
+
+const ConfirmationDialogManager = defineComponent({
+  name: "ConfirmationDialogManager",
+  setup() {
+    const { pendingConfirmation, confirm, cancel } = useActions();
+
+    return () => {
+      if (!pendingConfirmation?.action.confirm) return null;
+
+      return h(ConfirmDialog, {
+        confirm: pendingConfirmation.action.confirm,
+        onConfirm: confirm,
+        onCancel: cancel,
+      });
+    };
+  },
+});
+
+// ---------------------------------------------------------------------------
+// JSONUIProvider — combined provider for all contexts
+// ---------------------------------------------------------------------------
+
+/**
+ * Props for JSONUIProvider
+ */
+export interface JSONUIProviderProps {
+  registry: ComponentRegistry;
+  store?: StateStore;
+  initialState?: Record<string, unknown>;
+  handlers?: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<unknown> | unknown
+  >;
+  navigate?: (path: string) => void;
+  validationFunctions?: Record<
+    string,
+    (value: unknown, args?: Record<string, unknown>) => boolean
+  >;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+}
+
+/**
+ * Combined provider for all JSONUI contexts
+ */
+export const JSONUIProvider = defineComponent({
+  name: "JSONUIProvider",
+  props: {
+    registry: {
+      type: Object as PropType<ComponentRegistry>,
+      required: true,
+    },
+    store: {
+      type: Object as PropType<StateStore>,
+      default: undefined,
+    },
+    initialState: {
+      type: Object as PropType<Record<string, unknown>>,
+      default: undefined,
+    },
+    handlers: {
+      type: Object as PropType<
+        Record<
+          string,
+          (params: Record<string, unknown>) => Promise<unknown> | unknown
+        >
+      >,
+      default: undefined,
+    },
+    navigate: {
+      type: Function as PropType<(path: string) => void>,
+      default: undefined,
+    },
+    validationFunctions: {
+      type: Object as PropType<
+        Record<
+          string,
+          (value: unknown, args?: Record<string, unknown>) => boolean
+        >
+      >,
+      default: undefined,
+    },
+    onStateChange: {
+      type: Function as PropType<
+        (changes: Array<{ path: string; value: unknown }>) => void
+      >,
+      default: undefined,
+    },
+  },
+  setup(props, { slots }) {
+    return () =>
+      h(
+        StateProvider,
+        {
+          store: props.store,
+          initialState: props.initialState,
+          onStateChange: props.onStateChange,
+        },
+        {
+          default: () =>
+            h(VisibilityProvider, null, {
+              default: () =>
+                h(
+                  ActionProvider,
+                  { handlers: props.handlers, navigate: props.navigate },
+                  {
+                    default: () =>
+                      h(
+                        ValidationProvider,
+                        { customFunctions: props.validationFunctions },
+                        {
+                          default: () => [
+                            slots.default?.(),
+                            h(ConfirmationDialogManager),
+                          ],
+                        },
+                      ),
+                  },
+                ),
+            }),
+        },
+      );
+  },
+});
+
+// ============================================================================
+// defineRegistry
+// ============================================================================
+
+/**
+ * Result returned by defineRegistry
+ */
+export interface DefineRegistryResult {
+  registry: ComponentRegistry;
+  handlers: (
+    getSetState: () => SetState | undefined,
+    getState: () => StateModel,
+  ) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
+  executeAction: (
+    actionName: string,
+    params: Record<string, unknown> | undefined,
+    setState: SetState,
+    state?: StateModel,
+  ) => Promise<void>;
+}
+
+type DefineRegistryOptions<C extends Catalog> = {
+  components?: Components<C>;
+} & (CatalogHasActions<C> extends true
+  ? { actions: Actions<C> }
+  : { actions?: Actions<C> });
+
+type DefineRegistryComponentFn = (ctx: {
+  props: unknown;
+  children?: VNode | VNode[];
+  emit: (event: string) => void;
+  on: (event: string) => EventHandle;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}) => VNode | VNode[] | null | string;
+
+type DefineRegistryActionFn = (
+  params: Record<string, unknown> | undefined,
+  setState: SetState,
+  state: StateModel,
+) => Promise<void>;
+
+/**
+ * Create a registry from a catalog with components and/or actions.
+ *
+ * @example
+ * ```ts
+ * // Components only
+ * const { registry } = defineRegistry(catalog, {
+ *   components: {
+ *     Card: ({ props, children }) => h('div', { class: 'card' }, [props.title, children]),
+ *   },
+ * });
+ *
+ * // Both
+ * const { registry, handlers, executeAction } = defineRegistry(catalog, {
+ *   components: { ... },
+ *   actions: { ... },
+ * });
+ * ```
+ */
+export function defineRegistry<C extends Catalog>(
+  _catalog: C,
+  options: DefineRegistryOptions<C>,
+): DefineRegistryResult {
+  const registry: ComponentRegistry = {};
+
+  if (options.components) {
+    for (const [name, componentFn] of Object.entries(options.components)) {
+      registry[name] = defineComponent({
+        name: `JsonRenderRegistry_${name}`,
+        props: {
+          element: {
+            type: Object as PropType<UIElement>,
+            required: true,
+          },
+          emit: {
+            type: Function as PropType<(event: string) => void>,
+            required: true,
+          },
+          on: {
+            type: Function as PropType<(event: string) => EventHandle>,
+            required: true,
+          },
+          bindings: {
+            type: Object as PropType<Record<string, string>>,
+            default: undefined,
+          },
+          loading: {
+            type: Boolean,
+            default: undefined,
+          },
+        },
+        setup(registryProps, { slots }) {
+          return () =>
+            (componentFn as DefineRegistryComponentFn)({
+              props: registryProps.element.props,
+              children: slots.default?.(),
+              emit: registryProps.emit,
+              on: registryProps.on,
+              bindings: registryProps.bindings,
+              loading: registryProps.loading,
+            });
+        },
+      });
+    }
+  }
+
+  const actionMap = options.actions
+    ? (Object.entries(options.actions) as Array<
+        [string, DefineRegistryActionFn]
+      >)
+    : [];
+
+  const handlers = (
+    getSetState: () => SetState | undefined,
+    getState: () => StateModel,
+  ): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
+    const result: Record<
+      string,
+      (params: Record<string, unknown>) => Promise<void>
+    > = {};
+    for (const [name, actionFn] of actionMap) {
+      result[name] = async (params) => {
+        const setState = getSetState();
+        const state = getState();
+        if (setState) {
+          await actionFn(params, setState, state);
+        }
+      };
+    }
+    return result;
+  };
+
+  const executeAction = async (
+    actionName: string,
+    params: Record<string, unknown> | undefined,
+    setState: SetState,
+    state: StateModel = {},
+  ): Promise<void> => {
+    const entry = actionMap.find(([name]) => name === actionName);
+    if (entry) {
+      await entry[1](params, setState, state);
+    } else {
+      console.warn(`Unknown action: ${actionName}`);
+    }
+  };
+
+  return { registry, handlers, executeAction };
+}
+
+// ============================================================================
+// createRenderer
+// ============================================================================
+
+/**
+ * Props for renderers created with createRenderer
+ */
+export interface CreateRendererProps {
+  spec: Spec | null;
+  store?: StateStore;
+  state?: Record<string, unknown>;
+  onAction?: (actionName: string, params?: Record<string, unknown>) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  loading?: boolean;
+  fallback?: Component;
+}
+
+/**
+ * Component map type — maps component names to Vue components
+ */
+export type ComponentMap<
+  TComponents extends Record<string, { props: unknown }>,
+> = {
+  [K in keyof TComponents]: Component;
+};
+
+/**
+ * Create a renderer from a catalog
+ *
+ * @example
+ * ```typescript
+ * const DashboardRenderer = createRenderer(dashboardCatalog, {
+ *   Card: ({ props, children }) => h('div', { class: 'card' }, children),
+ *   Metric: ({ props }) => h('span', null, props.value),
+ * });
+ *
+ * // Usage in template
+ * <DashboardRenderer :spec="aiGeneratedSpec" :state="state" />
+ * ```
+ */
+export function createRenderer<
+  TDef extends SchemaDefinition,
+  TCatalog extends { components: Record<string, { props: unknown }> },
+>(
+  catalog: Catalog<TDef, TCatalog>,
+  components: ComponentMap<TCatalog["components"]>,
+): Component {
+  const registry: ComponentRegistry =
+    components as unknown as ComponentRegistry;
+
+  return defineComponent({
+    name: "CatalogRenderer",
+    props: {
+      spec: {
+        type: Object as PropType<Spec | null>,
+        default: null,
+      },
+      store: {
+        type: Object as PropType<StateStore>,
+        default: undefined,
+      },
+      state: {
+        type: Object as PropType<Record<string, unknown>>,
+        default: undefined,
+      },
+      onAction: {
+        type: Function as PropType<
+          (actionName: string, params?: Record<string, unknown>) => void
+        >,
+        default: undefined,
+      },
+      onStateChange: {
+        type: Function as PropType<
+          (changes: Array<{ path: string; value: unknown }>) => void
+        >,
+        default: undefined,
+      },
+      loading: {
+        type: Boolean,
+        default: undefined,
+      },
+      fallback: {
+        type: Object as PropType<Component>,
+        default: undefined,
+      },
+    },
+    setup(rendererProps) {
+      return () => {
+        // Build the action handlers proxy if onAction is provided
+        const actionHandlers = rendererProps.onAction
+          ? new Proxy(
+              {} as Record<
+                string,
+                (params: Record<string, unknown>) => void | Promise<void>
+              >,
+              {
+                get: (_target, prop: string) => {
+                  return (params: Record<string, unknown>) =>
+                    rendererProps.onAction!(prop, params);
+                },
+                has: () => true,
+              },
+            )
+          : undefined;
+
+        return h(
+          StateProvider,
+          {
+            store: rendererProps.store,
+            initialState: rendererProps.state,
+            onStateChange: rendererProps.onStateChange,
+          },
+          {
+            default: () =>
+              h(VisibilityProvider, null, {
+                default: () =>
+                  h(
+                    ActionProvider,
+                    { handlers: actionHandlers },
+                    {
+                      default: () =>
+                        h(ValidationProvider, null, {
+                          default: () => [
+                            h(Renderer, {
+                              spec: rendererProps.spec,
+                              registry,
+                              loading: rendererProps.loading,
+                              fallback: rendererProps.fallback,
+                            }),
+                            h(ConfirmationDialogManager),
+                          ],
+                        }),
+                    },
+                  ),
+              }),
+          },
+        );
+      };
+    },
+  });
+}

+ 104 - 0
packages/vue/src/schema.ts

@@ -0,0 +1,104 @@
+import { defineSchema } from "@json-render/core";
+
+/**
+ * The schema for @json-render/vue
+ *
+ * Defines:
+ * - Spec: A flat tree of elements with keys, types, props, and children references
+ * - Catalog: Components with props schemas, and optional actions
+ */
+export const schema = defineSchema(
+  (s) => ({
+    // What the AI-generated SPEC looks like
+    spec: s.object({
+      /** Root element key */
+      root: s.string(),
+      /** Flat map of elements by key */
+      elements: s.record(
+        s.object({
+          /** Component type from catalog */
+          type: s.ref("catalog.components"),
+          /** Component props */
+          props: s.propsOf("catalog.components"),
+          /** Child element keys (flat reference) */
+          children: s.array(s.string()),
+          /** Visibility condition */
+          visible: s.any(),
+        }),
+      ),
+    }),
+
+    // What the CATALOG must provide
+    catalog: s.object({
+      /** Component definitions */
+      components: s.map({
+        /** Zod schema for component props */
+        props: s.zod(),
+        /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
+        slots: s.array(s.string()),
+        /** Description for AI generation hints */
+        description: s.string(),
+        /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
+        example: s.any(),
+      }),
+      /** Action definitions (optional) */
+      actions: s.map({
+        /** Zod schema for action params */
+        params: s.zod(),
+        /** Description for AI generation hints */
+        description: s.string(),
+      }),
+    }),
+  }),
+  {
+    builtInActions: [
+      {
+        name: "setState",
+        description:
+          "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
+      },
+      {
+        name: "pushState",
+        description:
+          'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
+      },
+      {
+        name: "removeState",
+        description:
+          "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
+      },
+    ],
+    defaultRules: [
+      // Element integrity
+      "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
+      "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+
+      // Field placement
+      'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
+      'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
+
+      // State and data
+      "When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
+      'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
+
+      // Design quality
+      "Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
+      "For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
+      "Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
+    ],
+  },
+);
+
+/**
+ * Type for the Vue schema
+ */
+export type VueSchema = typeof schema;
+
+/**
+ * Infer the spec type from a catalog
+ */
+export type VueSpec<TCatalog> = typeof schema extends {
+  createCatalog: (catalog: TCatalog) => { _specType: infer S };
+}
+  ? S
+  : never;

+ 9 - 0
packages/vue/tsconfig.json

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

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

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

Fișier diff suprimat deoarece este prea mare
+ 496 - 22
pnpm-lock.yaml


+ 3 - 2
vitest.config.ts

@@ -3,11 +3,12 @@ import path from "path";
 
 
 export default defineConfig({
 export default defineConfig({
   resolve: {
   resolve: {
-    // Deduplicate React so tests don't get two copies
-    // (pnpm strict resolution can cause packages/react to resolve a different copy)
+    // Deduplicate React and Vue so tests don't get two copies
+    // (pnpm strict resolution can cause packages to resolve different copies)
     alias: {
     alias: {
       react: path.resolve(__dirname, "node_modules/react"),
       react: path.resolve(__dirname, "node_modules/react"),
       "react-dom": path.resolve(__dirname, "node_modules/react-dom"),
       "react-dom": path.resolve(__dirname, "node_modules/react-dom"),
+      vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
     },
     },
   },
   },
   test: {
   test: {

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff