瀏覽代碼

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 月之前
父節點
當前提交
db3a8b41e9
共有 55 個文件被更改,包括 7253 次插入36 次删除
  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.
 
 ```bash
+# for 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
-# or for mobile
+# or for React Native
 npm install @json-render/core @json-render/react-native
 # or for video
 npm install @json-render/core @json-render/remotion
 # or for PDF documents
 npm install @json-render/core @json-render/react-pdf
+# or for Vue
+npm install @json-render/core @json-render/vue
 ```
 
 ## 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
 - **Predictable** - JSON output matches your schema, every time
 - **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
 
 ## Quick Start
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
 |---------|-------------|
 | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
 | `@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/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
@@ -149,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
 <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)
 
 ```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" />
 
+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
 
 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.
 
-## Peer Dependencies
-
-json-render requires the following peer dependencies:
-
-- `react` ^19.0.0
-- `zod` ^4.0.0
-
-<PackageInstall packages="react zod" />
-
 ## For AI Integration
 
 To use json-render with AI models, you'll also need the Vercel AI SDK:

+ 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",
         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/react-native", href: "/docs/api/react-native" },
       { title: "@json-render/remotion", href: "/docs/api/remotion" },
+      { title: "@json-render/vue", href: "/docs/api/vue" },
       { 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
   "docs/api/core": "@json-render/core 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-native": "@json-render/react-native 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"],
+});

文件差異過大導致無法顯示
+ 496 - 22
pnpm-lock.yaml


+ 3 - 2
vitest.config.ts

@@ -3,11 +3,12 @@ import path from "path";
 
 export default defineConfig({
   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: {
       react: path.resolve(__dirname, "node_modules/react"),
       "react-dom": path.resolve(__dirname, "node_modules/react-dom"),
+      vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
     },
   },
   test: {

部分文件因文件數量過多而無法顯示