Эх сурвалжийг харах

solid: add renderer implementation (#201)

* solid: add renderer implementation

This adds a Solid renderer with the same core capabilities as the
existing framework packages, including typed registry helpers,
providers, streaming hooks, docs, and example integration.

* fix: solid reactivity bugs in validation + state

---------

Co-authored-by: Patrick <phall@Patricks-MacBook-Pro.local>
Patrick Hall 4 сар өмнө
parent
commit
9bb82604f0
50 өөрчлөгдсөн 7038 нэмэгдсэн , 109 устгасан
  1. 2 1
      .changeset/config.json
  2. 118 43
      README.md
  3. 201 0
      apps/web/app/(main)/docs/api/solid/page.mdx
  4. 85 14
      apps/web/app/(main)/docs/renderers/page.mdx
  5. 8 2
      apps/web/app/(main)/docs/skills/page.mdx
  6. 2 2
      apps/web/app/api/docs-chat/route.ts
  7. 2 1
      apps/web/lib/docs-navigation.ts
  8. 1 0
      apps/web/lib/page-titles.ts
  9. 4 1
      examples/vite-renderers/package.json
  10. 6 2
      examples/vite-renderers/src/main.ts
  11. 14 1
      examples/vite-renderers/src/react/registry.tsx
  12. 5 1
      examples/vite-renderers/src/shared/catalog-def.ts
  13. 6 0
      examples/vite-renderers/src/shared/handlers.ts
  14. 40 9
      examples/vite-renderers/src/shared/styles.css
  15. 19 0
      examples/vite-renderers/src/solid/App.tsx
  16. 32 0
      examples/vite-renderers/src/solid/DemoRenderer.tsx
  17. 5 0
      examples/vite-renderers/src/solid/catalog.ts
  18. 17 0
      examples/vite-renderers/src/solid/mount.tsx
  19. 194 0
      examples/vite-renderers/src/solid/registry.tsx
  20. 2 1
      examples/vite-renderers/src/spec.ts
  21. 3 1
      examples/vite-renderers/src/svelte/components/RendererBadge.svelte
  22. 11 0
      examples/vite-renderers/src/svelte/components/RendererTabs.svelte
  23. 16 1
      examples/vite-renderers/src/vue/registry.ts
  24. 7 1
      examples/vite-renderers/vite.config.ts
  25. 11 1
      package.json
  26. 243 0
      packages/solid/README.md
  27. 69 0
      packages/solid/package.json
  28. 154 0
      packages/solid/src/catalog-types.ts
  29. 193 0
      packages/solid/src/chained-actions.test.tsx
  30. 346 0
      packages/solid/src/contexts/actions.test.tsx
  31. 400 0
      packages/solid/src/contexts/actions.tsx
  32. 23 0
      packages/solid/src/contexts/repeat-scope.tsx
  33. 215 0
      packages/solid/src/contexts/state.test.tsx
  34. 208 0
      packages/solid/src/contexts/state.tsx
  35. 194 0
      packages/solid/src/contexts/validation.test.tsx
  36. 263 0
      packages/solid/src/contexts/validation.tsx
  37. 94 0
      packages/solid/src/contexts/visibility.test.tsx
  38. 53 0
      packages/solid/src/contexts/visibility.tsx
  39. 908 0
      packages/solid/src/dynamic-forms.test.tsx
  40. 430 0
      packages/solid/src/hooks.test.ts
  41. 881 0
      packages/solid/src/hooks.ts
  42. 109 0
      packages/solid/src/index.ts
  43. 50 0
      packages/solid/src/renderer.test.tsx
  44. 811 0
      packages/solid/src/renderer.tsx
  45. 117 0
      packages/solid/src/schema.ts
  46. 11 0
      packages/solid/tsconfig.json
  47. 12 0
      packages/solid/tsup.config.ts
  48. 231 25
      pnpm-lock.yaml
  49. 202 0
      skills/solid/SKILL.md
  50. 10 2
      vitest.config.mts

+ 2 - 1
.changeset/config.json

@@ -19,7 +19,8 @@
       "@json-render/xstate",
       "@json-render/image",
       "@json-render/mcp",
-      "@json-render/svelte"
+      "@json-render/svelte",
+      "@json-render/solid"
     ]
   ],
   "linked": [],

+ 118 - 43
README.md

@@ -21,6 +21,8 @@ npm install @json-render/core @json-render/react-email @react-email/components @
 npm install @json-render/core @json-render/vue
 # or for Svelte
 npm install @json-render/core @json-render/svelte
+# or for SolidJS
+npm install @json-render/core @json-render/solid
 ```
 
 ## Why json-render?
@@ -30,7 +32,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, Vue, Svelte (web), React Native (mobile) from the same catalog
+- **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
 - **Batteries Included** - 36 pre-built shadcn/ui components ready to use
 
 ## Quick Start
@@ -91,9 +93,7 @@ const { registry } = defineRegistry(catalog, {
       </div>
     ),
     Button: ({ props, emit }) => (
-      <button onClick={() => emit("press")}>
-        {props.label}
-      </button>
+      <button onClick={() => emit("press")}>{props.label}</button>
     ),
   },
 });
@@ -113,24 +113,25 @@ function Dashboard({ spec }) {
 
 ## Packages
 
-| Package | Description |
-|---------|-------------|
-| `@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/svelte` | Svelte 5 renderer with runes-based reactivity |
-| `@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 |
-| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
-| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
-| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
-| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
-| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
-| `@json-render/zustand` | Zustand adapter for `StateStore` |
-| `@json-render/jotai` | Jotai adapter for `StateStore` |
-| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
-| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
+| Package                     | Description                                                            |
+| --------------------------- | ---------------------------------------------------------------------- |
+| `@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/svelte`       | Svelte 5 renderer with runes-based reactivity                          |
+| `@json-render/solid`        | SolidJS renderer with fine-grained reactive contexts                   |
+| `@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                               |
+| `@json-render/react-pdf`    | React PDF renderer for generating PDF documents from specs             |
+| `@json-render/react-email`  | React Email renderer for HTML/plain-text emails from specs             |
+| `@json-render/image`        | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
+| `@json-render/codegen`      | Utilities for generating code from json-render UI trees                |
+| `@json-render/redux`        | Redux / Redux Toolkit adapter for `StateStore`                         |
+| `@json-render/zustand`      | Zustand adapter for `StateStore`                                       |
+| `@json-render/jotai`        | Jotai adapter for `StateStore`                                         |
+| `@json-render/xstate`       | XState Store (atom) adapter for `StateStore`                           |
+| `@json-render/mcp`          | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code              |
 
 ## Renderers
 
@@ -159,7 +160,7 @@ const spec = {
 
 // defineRegistry creates a type-safe component registry
 const { registry } = defineRegistry(catalog, { components });
-<Renderer spec={spec} registry={registry} />
+<Renderer spec={spec} registry={registry} />;
 ```
 
 ### Vue (UI)
@@ -199,6 +200,26 @@ const { registry } = defineRegistry(catalog, {
 // <Renderer spec={spec} registry={registry} />
 ```
 
+### Solid (UI)
+
+```tsx
+import { defineRegistry, Renderer } from "@json-render/solid";
+import { schema } from "@json-render/solid/schema";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: (renderProps) => <div>{renderProps.children}</div>,
+    Button: (renderProps) => (
+      <button onClick={() => renderProps.emit("press")}>
+        {renderProps.element.props.label as string}
+      </button>
+    ),
+  },
+});
+
+<Renderer spec={spec} registry={registry} />;
+```
+
 ### shadcn/ui (Web)
 
 ```tsx
@@ -229,7 +250,7 @@ const { registry } = defineRegistry(catalog, {
   },
 });
 
-<Renderer spec={spec} registry={registry} />
+<Renderer spec={spec} registry={registry} />;
 ```
 
 ### React Native (Mobile)
@@ -250,23 +271,40 @@ const catalog = defineCatalog(schema, {
 });
 
 const { registry } = defineRegistry(catalog, { components: {} });
-<Renderer spec={spec} registry={registry} />
+<Renderer spec={spec} registry={registry} />;
 ```
 
 ### Remotion (Video)
 
 ```tsx
 import { Player } from "@remotion/player";
-import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
+import {
+  Renderer,
+  schema,
+  standardComponentDefinitions,
+} from "@json-render/remotion";
 
 // Timeline spec format
 const spec = {
-  composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
+  composition: {
+    id: "video",
+    fps: 30,
+    width: 1920,
+    height: 1080,
+    durationInFrames: 300,
+  },
   tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
   clips: [
-    { id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
+    {
+      id: "clip-1",
+      trackId: "main",
+      component: "TitleCard",
+      props: { title: "Hello" },
+      from: 0,
+      durationInFrames: 90,
+    },
   ],
-  audio: { tracks: [] }
+  audio: { tracks: [] },
 };
 
 <Player
@@ -276,7 +314,7 @@ const spec = {
   fps={spec.composition.fps}
   compositionWidth={spec.composition.width}
   compositionHeight={spec.composition.height}
-/>
+/>;
 ```
 
 ### React PDF (Documents)
@@ -287,7 +325,11 @@ import { renderToBuffer } from "@json-render/react-pdf";
 const spec = {
   root: "doc",
   elements: {
-    doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
+    doc: {
+      type: "Document",
+      props: { title: "Invoice" },
+      children: ["page-1"],
+    },
     "page-1": {
       type: "Page",
       props: { size: "A4" },
@@ -301,8 +343,14 @@ const spec = {
     "table-1": {
       type: "Table",
       props: {
-        columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
-        rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
+        columns: [
+          { header: "Item", width: "60%" },
+          { header: "Price", width: "40%", align: "right" },
+        ],
+        rows: [
+          ["Widget A", "$10.00"],
+          ["Widget B", "$25.00"],
+        ],
       },
       children: [],
     },
@@ -327,7 +375,11 @@ const catalog = defineCatalog(schema, {
 const spec = {
   root: "html-1",
   elements: {
-    "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
+    "html-1": {
+      type: "Html",
+      props: { lang: "en", dir: "ltr" },
+      children: ["head-1", "body-1"],
+    },
     "head-1": { type: "Head", props: {}, children: [] },
     "body-1": {
       type: "Body",
@@ -336,11 +388,17 @@ const spec = {
     },
     "container-1": {
       type: "Container",
-      props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
+      props: {
+        style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
+      },
       children: ["heading-1", "text-1"],
     },
     "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
-    "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
+    "text-1": {
+      type: "Text",
+      props: { text: "Thanks for signing up." },
+      children: [],
+    },
   },
 };
 
@@ -425,8 +483,16 @@ Any prop value can be data-driven using expressions:
 {
   "type": "Icon",
   "props": {
-    "name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
-    "color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
+    "name": {
+      "$cond": { "$state": "/activeTab", "eq": "home" },
+      "$then": "home",
+      "$else": "home-outline"
+    },
+    "color": {
+      "$cond": { "$state": "/activeTab", "eq": "home" },
+      "$then": "#007AFF",
+      "$else": "#8E8E93"
+    }
   }
 }
 ```
@@ -445,7 +511,10 @@ Components can trigger actions, including the built-in `setState` action:
 ```json
 {
   "type": "Pressable",
-  "props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
+  "props": {
+    "action": "setState",
+    "actionParams": { "statePath": "/activeTab", "value": "home" }
+  },
   "children": ["home-icon"]
 }
 ```
@@ -459,9 +528,15 @@ React to state changes by triggering actions:
 ```json
 {
   "type": "Select",
-  "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
+  "props": {
+    "value": { "$bindState": "/form/country" },
+    "options": ["US", "Canada", "UK"]
+  },
   "watch": {
-    "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
+    "/form/country": {
+      "action": "loadCities",
+      "params": { "country": { "$state": "/form/country" } }
+    }
   }
 }
 ```
@@ -486,7 +561,7 @@ pnpm dev
 - Chat Example: run `pnpm dev` in `examples/chat`
 - Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
 - Vue Example: run `pnpm dev` in `examples/vue`
-- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
+- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
 - React Native example: run `npx expo start` in `examples/react-native`
 
 ## How It Works
@@ -496,7 +571,7 @@ flowchart LR
     A[User Prompt] --> B[AI + Catalog]
     B --> C[JSON Spec]
     C --> D[Renderer]
-    
+
     B -.- E([guardrailed])
     C -.- F([predictable])
     D -.- G([streamed])

+ 201 - 0
apps/web/app/(main)/docs/api/solid/page.mdx

@@ -0,0 +1,201 @@
+import { pageMetadata } from "@/lib/page-metadata";
+export const metadata = pageMetadata("docs/api/solid");
+
+# @json-render/solid
+
+SolidJS components, providers, and hooks for rendering json-render specs.
+
+## Installation
+
+<PackageInstall packages="@json-render/core @json-render/solid" />
+
+Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
+
+<PackageInstall packages="solid-js zod" />
+
+## Providers
+
+### StateProvider
+
+```tsx
+<StateProvider
+  initialState={{}}
+  onStateChange={(changes) => console.log(changes)}
+>
+  {/* children */}
+</StateProvider>
+```
+
+<table>
+  <thead>
+    <tr>
+      <th>Prop</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td>
+        <code>store</code>
+      </td>
+      <td>
+        <code>StateStore</code>
+      </td>
+      <td>
+        External store (controlled mode). When provided,{" "}
+        <code>initialState</code> and <code>onStateChange</code> are ignored.
+      </td>
+    </tr>
+    <tr>
+      <td>
+        <code>initialState</code>
+      </td>
+      <td>
+        <code>Record&lt;string, unknown&gt;</code>
+      </td>
+      <td>Initial state model for uncontrolled mode.</td>
+    </tr>
+    <tr>
+      <td>
+        <code>onStateChange</code>
+      </td>
+      <td>
+        <code>
+          {"(changes: Array<{ path: string; value: unknown }>) => void"}
+        </code>
+      </td>
+      <td>Called for uncontrolled state updates.</td>
+    </tr>
+  </tbody>
+</table>
+
+### ActionProvider
+
+```tsx
+<ActionProvider
+  handlers={{ submit: async (params) => {} }}
+  navigate={(path) => {}}
+>
+  {/* children */}
+</ActionProvider>
+```
+
+### VisibilityProvider
+
+```tsx
+<VisibilityProvider>{/* children */}</VisibilityProvider>
+```
+
+### ValidationProvider
+
+```tsx
+<ValidationProvider customFunctions={{ custom: (value) => Boolean(value) }}>
+  {/* children */}
+</ValidationProvider>
+```
+
+### JSONUIProvider
+
+Combined provider wrapper for state, visibility, validation, and actions.
+
+```tsx
+<JSONUIProvider
+  registry={registry}
+  initialState={{}}
+  handlers={handlers}
+  validationFunctions={validationFunctions}
+>
+  <Renderer spec={spec} registry={registry} />
+</JSONUIProvider>
+```
+
+## defineRegistry
+
+Create a typed component registry and action helpers from a catalog.
+
+```tsx
+const { registry, handlers, executeAction } = defineRegistry(catalog, {
+  components: {
+    Card: (renderProps) => <div>{renderProps.children}</div>,
+    Button: (renderProps) => (
+      <button onClick={() => renderProps.emit("press")}>
+        {renderProps.element.props.label as string}
+      </button>
+    ),
+  },
+  actions: {
+    submit: async (params, setState, state) => {
+      // custom action logic
+    },
+  },
+});
+```
+
+## Components
+
+### Renderer
+
+```tsx
+<Renderer spec={spec} registry={registry} loading={false} />
+```
+
+Renders a `Spec` tree using your registry.
+
+### createRenderer
+
+Build an app-level renderer from catalog + components:
+
+```tsx
+const AppRenderer = createRenderer(catalog, {
+  Card: (renderProps) => <div>{renderProps.children}</div>,
+});
+
+<AppRenderer spec={spec} state={{}} onAction={(name, params) => {}} />;
+```
+
+## Hooks
+
+- `useStateStore()`
+- `useStateValue(path)` - returns an accessor
+- `useStateBinding(path)` - returns `[Accessor<T | undefined>, setValue]`
+- `useVisibility()` / `useIsVisible(condition)`
+- `useActions()` / `useAction(binding)`
+- `useValidation()` / `useOptionalValidation()`
+- `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid`
+- `useBoundProp(value, bindingPath)`
+- `useUIStream(options)`
+- `useChatUI(options)`
+
+## Built-in Actions
+
+`ActionProvider` handles these built-in actions:
+
+- `setState`
+- `pushState`
+- `removeState`
+- `validateForm`
+
+## Component Props
+
+Registry components receive:
+
+```ts
+interface ComponentRenderProps<P = Record<string, unknown>> {
+  element: UIElement<string, P>;
+  children?: JSX.Element;
+  emit: (event: string) => void;
+  on: (event: string) => EventHandle;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+```
+
+Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`).
+
+## Reactivity Notes
+
+- Keep changing reads in JSX expressions, `createMemo`, or `createEffect`.
+- Avoid props destructuring in component signatures when you need live updates.
+- `StateProvider` and other contexts expose getter-backed values so consumers read live signals.
+- `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions.

+ 85 - 14
apps/web/app/(main)/docs/renderers/page.mdx

@@ -1,5 +1,5 @@
-import { pageMetadata } from "@/lib/page-metadata"
-export const metadata = pageMetadata("docs/renderers")
+import { pageMetadata } from "@/lib/page-metadata";
+export const metadata = pageMetadata("docs/renderers");
 
 # Renderers
 
@@ -22,37 +22,65 @@ All renderers share the same workflow:
   <tbody>
     <tr>
       <td>React</td>
-      <td><code>@json-render/react</code></td>
+      <td>
+        <code>@json-render/react</code>
+      </td>
       <td>React component tree</td>
     </tr>
     <tr>
       <td>Vue</td>
-      <td><code>@json-render/vue</code></td>
+      <td>
+        <code>@json-render/vue</code>
+      </td>
       <td>Vue 3 component tree</td>
     </tr>
+    <tr>
+      <td>Svelte</td>
+      <td>
+        <code>@json-render/svelte</code>
+      </td>
+      <td>Svelte 5 component tree</td>
+    </tr>
+    <tr>
+      <td>Solid</td>
+      <td>
+        <code>@json-render/solid</code>
+      </td>
+      <td>SolidJS component tree</td>
+    </tr>
     <tr>
       <td>shadcn/ui</td>
-      <td><code>@json-render/shadcn</code></td>
+      <td>
+        <code>@json-render/shadcn</code>
+      </td>
       <td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
     </tr>
     <tr>
       <td>React Native</td>
-      <td><code>@json-render/react-native</code></td>
+      <td>
+        <code>@json-render/react-native</code>
+      </td>
       <td>Native mobile views</td>
     </tr>
     <tr>
       <td>Image</td>
-      <td><code>@json-render/image</code></td>
+      <td>
+        <code>@json-render/image</code>
+      </td>
       <td>SVG / PNG (via Satori)</td>
     </tr>
     <tr>
       <td>React PDF</td>
-      <td><code>@json-render/react-pdf</code></td>
+      <td>
+        <code>@json-render/react-pdf</code>
+      </td>
       <td>PDF documents</td>
     </tr>
     <tr>
       <td>Remotion</td>
-      <td><code>@json-render/remotion</code></td>
+      <td>
+        <code>@json-render/remotion</code>
+      </td>
       <td>Video compositions</td>
     </tr>
   </tbody>
@@ -67,7 +95,7 @@ import { defineRegistry, Renderer } from "@json-render/react";
 import { schema } from "@json-render/react/schema";
 
 const { registry } = defineRegistry(catalog, { components });
-<Renderer spec={spec} registry={registry} />
+<Renderer spec={spec} registry={registry} />;
 ```
 
 Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
@@ -90,6 +118,42 @@ const { registry } = defineRegistry(catalog, {
 
 Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
 
+## Svelte
+
+Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support.
+
+```typescript
+import { defineRegistry, Renderer } from "@json-render/svelte";
+import { schema } from "@json-render/svelte/schema";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => /* Svelte snippet */,
+  },
+});
+```
+
+See the [@json-render/svelte API reference](/docs/api/svelte) for details.
+
+## Solid
+
+SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions.
+
+```tsx
+import { defineRegistry, Renderer } from "@json-render/solid";
+import { schema } from "@json-render/solid/schema";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: (renderProps) => <div>{renderProps.children}</div>,
+  },
+});
+
+<Renderer spec={spec} registry={registry} />;
+```
+
+See the [@json-render/solid API reference](/docs/api/solid) for details.
+
 ## shadcn/ui
 
 36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
@@ -125,7 +189,10 @@ Render specs as native mobile views. Includes 25+ standard components and standa
 ```tsx
 import { defineCatalog } from "@json-render/core";
 import { schema } from "@json-render/react-native/schema";
-import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
+import {
+  standardComponentDefinitions,
+  standardActionDefinitions,
+} from "@json-render/react-native/catalog";
 import { defineRegistry, Renderer } from "@json-render/react-native";
 
 const catalog = defineCatalog(schema, {
@@ -134,7 +201,7 @@ const catalog = defineCatalog(schema, {
 });
 
 const { registry } = defineRegistry(catalog, { components: {} });
-<Renderer spec={spec} registry={registry} />
+<Renderer spec={spec} registry={registry} />;
 ```
 
 See the [@json-render/react-native API reference](/docs/api/react-native) for details.
@@ -159,7 +226,11 @@ See the [@json-render/image API reference](/docs/api/image) for details.
 Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
 
 ```typescript
-import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
+import {
+  renderToBuffer,
+  renderToStream,
+  renderToFile,
+} from "@json-render/react-pdf";
 
 const buffer = await renderToBuffer(spec);
 const stream = await renderToStream(spec);
@@ -185,7 +256,7 @@ import { Renderer } from "@json-render/remotion";
   fps={spec.composition.fps}
   compositionWidth={spec.composition.width}
   compositionHeight={spec.composition.height}
-/>
+/>;
 ```
 
 Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.

+ 8 - 2
apps/web/app/(main)/docs/skills/page.mdx

@@ -1,6 +1,6 @@
-import { pageMetadata } from "@/lib/page-metadata"
+import { pageMetadata } from "@/lib/page-metadata";
 
-export const metadata = pageMetadata("docs/skills")
+export const metadata = pageMetadata("docs/skills");
 
 # Skills
 
@@ -18,6 +18,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
 - **remotion** — Remotion renderer for video generation from JSON timeline specs.
 - **vue** — Vue 3 renderer for Vue component trees.
 - **svelte** — Svelte 5 renderer for Svelte component trees.
+- **solid** — SolidJS renderer for fine-grained reactive component trees.
 - **codegen** — Code generation utilities for building custom exporters.
 - **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code.
 - **redux** — Redux adapter for json-render's `StateStore` interface.
@@ -38,6 +39,7 @@ npx skills add vercel-labs/json-render --skill image
 npx skills add vercel-labs/json-render --skill remotion
 npx skills add vercel-labs/json-render --skill vue
 npx skills add vercel-labs/json-render --skill svelte
+npx skills add vercel-labs/json-render --skill solid
 npx skills add vercel-labs/json-render --skill codegen
 npx skills add vercel-labs/json-render --skill mcp
 npx skills add vercel-labs/json-render --skill redux
@@ -88,6 +90,10 @@ Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue
 
 Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration.
 
+## solid
+
+Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming.
+
 ## codegen
 
 Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation.

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

@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
 
 GitHub repository: https://github.com/vercel-labs/json-render
 Documentation: https://json-render.dev/docs
-npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
-Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
+npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
+Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
 
 You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 

+ 2 - 1
apps/web/lib/docs-navigation.ts

@@ -92,7 +92,7 @@ export const docsNavigation: NavSection[] = [
         external: true,
       },
       {
-        title: "Renders with Vite (Vue / React / Svelte)",
+        title: "Renders with Vite (Vue / React / Svelte / Solid)",
         href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
         external: true,
       },
@@ -133,6 +133,7 @@ export const docsNavigation: NavSection[] = [
       { title: "@json-render/remotion", href: "/docs/api/remotion" },
       { title: "@json-render/vue", href: "/docs/api/vue" },
       { title: "@json-render/svelte", href: "/docs/api/svelte" },
+      { title: "@json-render/solid", href: "/docs/api/solid" },
       { title: "@json-render/codegen", href: "/docs/api/codegen" },
       { title: "@json-render/mcp", href: "/docs/api/mcp" },
       { title: "@json-render/redux", href: "/docs/api/redux" },

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

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

+ 4 - 1
examples/vite-renderers/package.json

@@ -12,10 +12,12 @@
   "dependencies": {
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
+    "@json-render/solid": "workspace:*",
     "@json-render/svelte": "workspace:*",
     "@json-render/vue": "workspace:*",
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
+    "solid-js": "^1.9.11",
     "svelte": "^5.49.2",
     "vue": "^3.5.29",
     "zod": "4.3.6"
@@ -27,6 +29,7 @@
     "@vitejs/plugin-react": "^5.1.4",
     "@vitejs/plugin-vue": "^6.0.4",
     "typescript": "^5.9.3",
-    "vite": "^7.3.1"
+    "vite": "^7.3.1",
+    "vite-plugin-solid": "^2.11.10"
   }
 }

+ 6 - 2
examples/vite-renderers/src/main.ts

@@ -1,7 +1,7 @@
 import "./shared/styles.css";
 import { demoSpec } from "./spec";
 
-type Renderer = "vue" | "react" | "svelte";
+type Renderer = "vue" | "react" | "svelte" | "solid";
 
 const container = document.getElementById("renderer-root") as HTMLElement;
 
@@ -18,10 +18,14 @@ async function switchTo(renderer: Renderer) {
     const mod = await import("./react/mount.tsx");
     mod.mount(container, renderer, demoSpec);
     unmountCurrent = mod.unmount;
-  } else {
+  } else if (renderer === "svelte") {
     const mod = await import("./svelte/mount.ts");
     mod.mount(container, renderer, demoSpec);
     unmountCurrent = mod.unmount;
+  } else {
+    const mod = await import("./solid/mount.tsx");
+    mod.mount(container, renderer, demoSpec);
+    unmountCurrent = mod.unmount;
   }
 }
 

+ 14 - 1
examples/vite-renderers/src/react/registry.tsx

@@ -123,7 +123,9 @@ export const components: Components<AppCatalog> = {
         ? "Rendered with Vue"
         : props.renderer === "react"
           ? "Rendered with React"
-          : "Rendered with Svelte"}
+          : props.renderer === "svelte"
+            ? "Rendered with Svelte"
+            : "Rendered with Solid"}
     </span>
   ),
 
@@ -164,6 +166,17 @@ export const components: Components<AppCatalog> = {
         >
           Svelte
         </button>
+        <button
+          onClick={() => emit("pressSolid")}
+          className={[
+            "json-render-renderer-tab",
+            props.renderer === "solid" && "json-render-renderer-tab--active",
+          ]
+            .filter(Boolean)
+            .join(" ")}
+        >
+          Solid
+        </button>
       </div>
     </div>
   ),

+ 5 - 1
examples/vite-renderers/src/shared/catalog-def.ts

@@ -65,7 +65,7 @@ export const catalogDef = {
       props: z.object({ renderer: z.string() }),
       slots: [],
       description:
-        "Segmented tab control for switching between Vue, React, and Svelte renderers",
+        "Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
     },
     RendererBadge: {
       props: z.object({ renderer: z.string() }),
@@ -99,5 +99,9 @@ export const catalogDef = {
       params: z.object({}),
       description: "Switch to the Svelte renderer",
     },
+    switchToSolid: {
+      params: z.object({}),
+      description: "Switch to the Solid renderer",
+    },
   },
 };

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

@@ -10,6 +10,7 @@ export const actionStubs = {
   switchToVue: async () => {},
   switchToReact: async () => {},
   switchToSvelte: async () => {},
+  switchToSolid: async () => {},
 };
 
 /** Creates action handlers that close over the state store's get/set */
@@ -52,5 +53,10 @@ export function makeHandlers(get: Get, set: Set) {
         new CustomEvent("switch-renderer", { detail: "svelte" }),
       );
     },
+    switchToSolid: async () => {
+      document.dispatchEvent(
+        new CustomEvent("switch-renderer", { detail: "solid" }),
+      );
+    },
   };
 }

+ 40 - 9
examples/vite-renderers/src/shared/styles.css

@@ -11,9 +11,15 @@
   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; }
+.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 ----------------------------------------------------------------- */
 
@@ -50,12 +56,22 @@
   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--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; }
+.json-render-text--medium {
+  font-weight: 500;
+}
+.json-render-text--bold {
+  font-weight: 700;
+}
 
 /* ---- Button --------------------------------------------------------------- */
 
@@ -211,7 +227,7 @@
   transition: background 0.15s;
 }
 
-.json-render-renderer-tab:first-child {
+.json-render-renderer-tab:not(:last-child) {
   border-right: 1px solid #e5e7eb;
 }
 
@@ -261,3 +277,18 @@
   background-color: #ff3e00;
   color: white;
 }
+
+.renderer-solid .json-render-renderer-badge {
+  color: #2c4f7c;
+  background-color: #2c4f7c18;
+  border-color: #2c4f7c40;
+}
+
+.renderer-solid .json-render-renderer-dot {
+  background-color: #2c4f7c;
+}
+
+.renderer-solid .json-render-renderer-tab--active {
+  background-color: #2c4f7c;
+  color: white;
+}

+ 19 - 0
examples/vite-renderers/src/solid/App.tsx

@@ -0,0 +1,19 @@
+import type { Spec } from "@json-render/core";
+import { StateProvider } from "@json-render/solid";
+import { DemoRenderer } from "./DemoRenderer";
+
+export function App(props: { initialRenderer?: string; spec: Spec }) {
+  const renderer = props.initialRenderer ?? "solid";
+  const initialState = {
+    ...props.spec.state,
+    renderer,
+  };
+
+  return (
+    <div class={`renderer-${renderer}`}>
+      <StateProvider initialState={initialState}>
+        <DemoRenderer spec={props.spec} />
+      </StateProvider>
+    </div>
+  );
+}

+ 32 - 0
examples/vite-renderers/src/solid/DemoRenderer.tsx

@@ -0,0 +1,32 @@
+import type { Spec } from "@json-render/core";
+import {
+  ActionProvider,
+  ValidationProvider,
+  VisibilityProvider,
+  Renderer,
+  defineRegistry,
+  useStateStore,
+} from "@json-render/solid";
+import { catalog } from "./catalog";
+import { components } from "./registry";
+import { actionStubs, makeHandlers } from "../shared/handlers";
+
+const { registry } = defineRegistry(catalog, {
+  components,
+  actions: actionStubs,
+});
+
+export function DemoRenderer(props: { spec: Spec }) {
+  const stateStore = useStateStore();
+  const handlers = makeHandlers(stateStore.get, stateStore.set);
+
+  return (
+    <ActionProvider handlers={handlers}>
+      <VisibilityProvider>
+        <ValidationProvider>
+          <Renderer spec={props.spec} registry={registry} />
+        </ValidationProvider>
+      </VisibilityProvider>
+    </ActionProvider>
+  );
+}

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

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

+ 17 - 0
examples/vite-renderers/src/solid/mount.tsx

@@ -0,0 +1,17 @@
+import { render } from "solid-js/web";
+import type { Spec } from "@json-render/core";
+import { App } from "./App";
+
+let dispose: (() => void) | null = null;
+
+export function mount(container: HTMLElement, renderer: string, spec: Spec) {
+  dispose = render(
+    () => <App initialRenderer={renderer} spec={spec} />,
+    container,
+  );
+}
+
+export function unmount() {
+  dispose?.();
+  dispose = null;
+}

+ 194 - 0
examples/vite-renderers/src/solid/registry.tsx

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

+ 2 - 1
examples/vite-renderers/src/spec.ts

@@ -10,7 +10,7 @@ export const demoSpec: Spec = {
       {
         id: 2,
         title:
-          "Try @json-render/vue, @json-render/react, and @json-render/svelte",
+          "Try @json-render/vue, @json-render/react, @json-render/svelte, and @json-render/solid",
         completed: false,
       },
       { id: 3, title: "Build something awesome", completed: false },
@@ -49,6 +49,7 @@ export const demoSpec: Spec = {
         pressVue: { action: "switchToVue" },
         pressReact: { action: "switchToReact" },
         pressSvelte: { action: "switchToSvelte" },
+        pressSolid: { action: "switchToSolid" },
       },
     },
 

+ 3 - 1
examples/vite-renderers/src/svelte/components/RendererBadge.svelte

@@ -12,5 +12,7 @@
     ? "Rendered with Vue"
     : props.renderer === "react"
       ? "Rendered with React"
-      : "Rendered with Svelte"}
+      : props.renderer === "svelte"
+        ? "Rendered with Svelte"
+        : "Rendered with Solid"}
 </span>

+ 11 - 0
examples/vite-renderers/src/svelte/components/RendererTabs.svelte

@@ -42,5 +42,16 @@
         .join(" ")}>
       Svelte
     </button>
+    <button
+      type="button"
+      onclick={() => emit("pressSolid")}
+      class={[
+        "json-render-renderer-tab",
+        props.renderer === "solid" && "json-render-renderer-tab--active",
+      ]
+        .filter(Boolean)
+        .join(" ")}>
+      Solid
+    </button>
   </div>
 </div>

+ 16 - 1
examples/vite-renderers/src/vue/registry.ts

@@ -132,7 +132,9 @@ export const components: Components<AppCatalog> = {
         ? "Rendered with Vue"
         : props.renderer === "react"
           ? "Rendered with React"
-          : "Rendered with Svelte",
+          : props.renderer === "svelte"
+            ? "Rendered with Svelte"
+            : "Rendered with Solid",
     ]),
 
   RendererTabs: ({ props, emit }) =>
@@ -178,6 +180,19 @@ export const components: Components<AppCatalog> = {
           },
           "Svelte",
         ),
+        h(
+          "button",
+          {
+            onClick: () => emit("pressSolid"),
+            class: [
+              "json-render-renderer-tab",
+              props.renderer === "solid" && "json-render-renderer-tab--active",
+            ]
+              .filter(Boolean)
+              .join(" "),
+          },
+          "Solid",
+        ),
       ]),
     ]),
 };

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

@@ -2,7 +2,13 @@ import { defineConfig } from "vite";
 import vue from "@vitejs/plugin-vue";
 import react from "@vitejs/plugin-react";
 import { svelte } from "@sveltejs/vite-plugin-svelte";
+import solid from "vite-plugin-solid";
 
 export default defineConfig({
-  plugins: [svelte(), vue(), react({ include: /\.tsx$/ })],
+  plugins: [
+    svelte(),
+    vue(),
+    react({ include: /src\/react\/.*\.tsx$/ }),
+    solid({ include: [/src\/solid\/.*\.tsx$/, /packages\/solid\/.*\.tsx$/] }),
+  ],
 });

+ 11 - 1
package.json

@@ -32,6 +32,7 @@
     "@sveltejs/vite-plugin-svelte": "^6.2.4",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.1",
+    "@solidjs/testing-library": "^0.8.10",
     "@testing-library/svelte": "^5.2.0",
     "@types/react": "^19.2.3",
     "husky": "^9.1.7",
@@ -40,9 +41,11 @@
     "prettier": "^3.7.4",
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
+    "solid-js": "^1.9.11",
     "svelte": "^5.0.0",
     "turbo": "^2.7.4",
     "typescript": "5.9.2",
+    "vite-plugin-solid": "^2.11.10",
     "vitest": "^4.0.17"
   },
   "packageManager": "pnpm@10.29.3",
@@ -51,5 +54,12 @@
   },
   "lint-staged": {
     "*.{ts,tsx}": "prettier --write"
-  }
+  },
+  "workspaces": [
+    "apps/*",
+    "examples/*",
+    "examples/stripe-app/*",
+    "packages/*",
+    "tests/*"
+  ]
 }

+ 243 - 0
packages/solid/README.md

@@ -0,0 +1,243 @@
+# @json-render/solid
+
+SolidJS renderer for json-render. Turn JSON specs into Solid components with data binding, visibility, actions, validation, and streaming.
+
+## Installation
+
+```bash
+npm install @json-render/core @json-render/solid zod
+```
+
+Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
+
+## Quick Start
+
+### 1. Create a Catalog
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/solid/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 with optional state binding",
+    },
+  },
+  actions: {
+    submit: { description: "Submit the form" },
+    cancel: { description: "Cancel and close" },
+  },
+});
+```
+
+### 2. Define Component Implementations
+
+`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions.
+
+```tsx
+import { defineRegistry, useBoundProp } from "@json-render/solid";
+import { catalog } from "./catalog";
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: (renderProps) => (
+      <div class="card">
+        <h3>{renderProps.element.props.title as string}</h3>
+        {renderProps.children}
+      </div>
+    ),
+    Button: (renderProps) => (
+      <button onClick={() => renderProps.emit("press")}>
+        {renderProps.element.props.label as string}
+      </button>
+    ),
+    Input: (renderProps) => {
+      const [value, setValue] = useBoundProp(
+        renderProps.element.props.value,
+        renderProps.bindings?.value,
+      );
+      return (
+        <label>
+          {renderProps.element.props.label as string}
+          <input
+            value={String(value() ?? "")}
+            placeholder={String(renderProps.element.props.placeholder ?? "")}
+            onInput={(e) => setValue(e.currentTarget.value)}
+          />
+        </label>
+      );
+    },
+  },
+  actions: {
+    submit: async () => {},
+    cancel: async () => {},
+  },
+});
+```
+
+### 3. Render Specs
+
+```tsx
+import { Renderer, StateProvider, ActionProvider } from "@json-render/solid";
+import { registry } from "./registry";
+
+export function App(props: { spec: any }) {
+  return (
+    <StateProvider initialState={{ form: { name: "" } }}>
+      <ActionProvider handlers={{ submit: () => console.log("submit") }}>
+        <Renderer spec={props.spec} registry={registry} />
+      </ActionProvider>
+    </StateProvider>
+  );
+}
+```
+
+## Spec Format
+
+`@json-render/solid` uses the same flat element map format as React/Vue:
+
+```typescript
+interface Spec {
+  root: string;
+  elements: Record<string, UIElement>;
+  state?: Record<string, unknown>;
+}
+
+interface UIElement {
+  type: string;
+  props: Record<string, unknown>;
+  children?: string[];
+  visible?: VisibilityCondition;
+  watch?: Record<string, ActionBinding | ActionBinding[]>;
+}
+```
+
+## Providers
+
+| Provider              | Purpose                                              |
+| --------------------- | ---------------------------------------------------- |
+| `StateProvider`       | State model and JSON Pointer read/write APIs         |
+| `ActionProvider`      | Action dispatch, loading tracking, confirmation flow |
+| `VisibilityProvider`  | Visibility condition evaluation from current state   |
+| `ValidationProvider`  | Field-level and full-form validation                 |
+| `RepeatScopeProvider` | Repeat context (`$item`, `$index`, `$bindItem`)      |
+| `JSONUIProvider`      | Combined provider wiring for renderer trees          |
+
+### External Store (Controlled Mode)
+
+Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or the component returned by `createRenderer`.
+When `store` is provided, `initialState` and `onStateChange` are ignored.
+
+```tsx
+import { createStateStore, StateProvider } from "@json-render/solid";
+
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>{/* ... */}</StateProvider>;
+```
+
+## Hooks
+
+| Hook                                          | Purpose                                               |
+| --------------------------------------------- | ----------------------------------------------------- |
+| `useStateStore()`                             | Access `state`, `get`, `set`, `update`, `getSnapshot` |
+| `useStateValue(path)`                         | Read a value by JSON Pointer path via accessor        |
+| `useStateBinding(path)`                       | Legacy two-way binding helper returning an accessor   |
+| `useVisibility()` / `useIsVisible()`          | Visibility context and checks                         |
+| `useActions()` / `useAction()`                | Action context and single-action helper               |
+| `useValidation()` / `useOptionalValidation()` | Validation context (throwing/non-throwing)            |
+| `useFieldValidation(path, config)`            | Field state accessors plus validate/touch/clear       |
+| `useBoundProp(value, binding)`                | Fine-grained two-way binding helper                   |
+| `useUIStream(options)`                        | Stream UI specs from an endpoint                      |
+| `useChatUI(options)`                          | Chat-style spec generation hook                       |
+
+## Built-in Actions
+
+These actions are available in the Solid schema and handled by `ActionProvider`:
+
+- `setState`
+- `pushState`
+- `removeState`
+- `validateForm`
+
+`setState`/`pushState`/`removeState` mutate the state model. `validateForm` validates registered fields and writes `{ valid, errors }` to state (`/formValidation` by default).
+
+## Events and Action Binding
+
+Components can use either `emit("event")` or `on("event")`.
+
+- `emit` fires named event bindings directly.
+- `on` returns an `EventHandle` with `emit`, `bound`, and `shouldPreventDefault`.
+
+This mirrors the React package API while preserving Solid's fine-grained reactivity.
+
+## Streaming
+
+`useUIStream` and `useChatUI` support JSON patch streaming and mixed text/spec data parts.
+
+```tsx
+import { useUIStream } from "@json-render/solid";
+
+const stream = useUIStream({ api: "/api/generate" });
+await stream.send("Build me a dashboard");
+```
+
+## Key Exports
+
+| Export             | Purpose                                                                    |
+| ------------------ | -------------------------------------------------------------------------- |
+| `defineRegistry`   | Create catalog-aware component and action registry helpers                 |
+| `Renderer`         | Render a `Spec` with a component registry                                  |
+| `createRenderer`   | Build an app-level renderer with provider wiring                           |
+| `JSONUIProvider`   | Combined provider tree (`state` + `visibility` + `validation` + `actions`) |
+| `schema`           | Solid element schema with built-in actions                                 |
+| `createStateStore` | Framework-agnostic in-memory `StateStore`                                  |
+
+### Types
+
+| Export                      | Purpose                                                  |
+| --------------------------- | -------------------------------------------------------- |
+| `ComponentContext`          | Catalog-aware component context type                     |
+| `BaseComponentProps`        | Catalog-agnostic component props type                    |
+| `EventHandle`               | Event metadata (`emit`, `bound`, `shouldPreventDefault`) |
+| `StateStore`                | Controlled state backend interface                       |
+| `StateModel`                | Renderer state model type                                |
+| `SolidSchema` / `SolidSpec` | Solid schema/spec types                                  |
+
+## Differences from `@json-render/react`
+
+Most APIs are intentionally aligned, but there are runtime behavior differences due to Solid:
+
+- Solid components run once, then update via signals.
+- Keep changing reads inside JSX expressions, `createMemo`, or `createEffect`.
+- Avoid props destructuring in component signatures when values should remain reactive.
+- Hooks that read changing state return accessors; call them inside JSX or effects.
+
+## Documentation
+
+Full docs: [json-render.dev/docs/api/solid](https://json-render.dev/docs/api/solid)
+
+## License
+
+Apache-2.0

+ 69 - 0
packages/solid/package.json

@@ -0,0 +1,69 @@
+{
+  "name": "@json-render/solid",
+  "version": "0.12.0",
+  "license": "Apache-2.0",
+  "description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
+  "keywords": [
+    "json",
+    "ui",
+    "solid",
+    "solidjs",
+    "ai",
+    "generative-ui",
+    "llm",
+    "renderer",
+    "streaming",
+    "components"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/solid"
+  },
+  "homepage": "https://json-render.dev",
+  "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",
+    "check-types": "tsc --noEmit",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "esbuild-plugin-solid": "^0.6.0",
+    "solid-js": "^1.9.0",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "zod": "^4.3.6"
+  },
+  "peerDependencies": {
+    "solid-js": "^1.9.0",
+    "zod": "^4.0.0"
+  }
+}

+ 154 - 0
packages/solid/src/catalog-types.ts

@@ -0,0 +1,154 @@
+import type { JSX } from "solid-js";
+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 = (ctx: BaseComponentProps<{ title?: string }>) => (
+ *   <div>{ctx.props.title}{ctx.children}</div>
+ * );
+ * ```
+ */
+export interface BaseComponentProps<P = Record<string, unknown>> {
+  props: P;
+  children?: JSX.Element;
+  /** 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 <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
+ * }
+ */
+export interface ComponentContext<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> extends BaseComponentProps<InferComponentProps<C, K>> {}
+
+/**
+ * Component render function type for Solid
+ * @example
+ * const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => (
+ *   <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
+ * );
+ */
+export type ComponentFn<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> = (ctx: ComponentContext<C, K>) => JSX.Element;
+
+/**
+ * Registry of all component render functions for a catalog
+ * @example
+ * const components: Components<typeof myCatalog> = {
+ *   Button: (ctx) => <button>{ctx.props.label}</button>,
+ *   Input: (ctx) => <input placeholder={ctx.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;

+ 193 - 0
packages/solid/src/chained-actions.test.tsx

@@ -0,0 +1,193 @@
+import { describe, it, expect } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
+import type { Spec } from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  type ComponentRenderProps,
+} from "./renderer";
+import { useStateStore } from "./contexts/state";
+
+// DO NOT destructure props — Solid requires proxy access
+function Button(props: ComponentRenderProps<{ label: string }>) {
+  return (
+    <button data-testid="btn" onClick={() => props.emit("press")}>
+      {props.element.props.label}
+    </button>
+  );
+}
+
+function Text(props: ComponentRenderProps<{ text: unknown }>) {
+  const display = () => {
+    const v = props.element.props.text;
+    return typeof v === "string" ? v : JSON.stringify(v);
+  };
+  return <span data-testid={`text-${props.element.type}`}>{display()}</span>;
+}
+
+let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
+
+function StateProbe() {
+  const ctx = useStateStore();
+  probeGetSnapshot = ctx.getSnapshot;
+  return <div data-testid="state-probe" />;
+}
+
+const registry = {
+  Button,
+  Text,
+};
+
+function getProbeState(): Record<string, unknown> {
+  return probeGetSnapshot!();
+}
+
+describe("chained actions: live $state resolution (#141)", () => {
+  it("setState after pushState sees the post-push value via $state", async () => {
+    probeGetSnapshot = undefined;
+    const spec: Spec = {
+      state: { items: ["initial"], observed: "not yet set" },
+      root: "main",
+      elements: {
+        main: {
+          type: "Button",
+          props: { label: "Add Item" },
+          on: {
+            press: [
+              {
+                action: "pushState",
+                params: { statePath: "/items", value: "new-item" },
+              },
+              {
+                action: "setState",
+                params: {
+                  statePath: "/observed",
+                  value: { $state: "/items" },
+                },
+              },
+            ],
+          },
+        },
+      },
+    };
+
+    function App() {
+      return (
+        <JSONUIProvider registry={registry} initialState={spec.state}>
+          <Renderer spec={spec} registry={registry} />
+          <StateProbe />
+        </JSONUIProvider>
+      );
+    }
+
+    render(() => <App />);
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getProbeState();
+      expect(state.items).toEqual(["initial", "new-item"]);
+      expect(state.observed).toEqual(["initial", "new-item"]);
+    });
+  });
+
+  it("multiple pushState + setState chain resolves correctly", async () => {
+    probeGetSnapshot = undefined;
+    const spec: Spec = {
+      state: { items: [], snapshot: null },
+      root: "main",
+      elements: {
+        main: {
+          type: "Button",
+          props: { label: "Go" },
+          on: {
+            press: [
+              {
+                action: "pushState",
+                params: { statePath: "/items", value: "a" },
+              },
+              {
+                action: "pushState",
+                params: { statePath: "/items", value: "b" },
+              },
+              {
+                action: "setState",
+                params: {
+                  statePath: "/snapshot",
+                  value: { $state: "/items" },
+                },
+              },
+            ],
+          },
+        },
+      },
+    };
+
+    function App() {
+      return (
+        <JSONUIProvider registry={registry} initialState={spec.state}>
+          <Renderer spec={spec} registry={registry} />
+          <StateProbe />
+        </JSONUIProvider>
+      );
+    }
+
+    render(() => <App />);
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getProbeState();
+      expect(state.items).toEqual(["a", "b"]);
+      expect(state.snapshot).toEqual(["a", "b"]);
+    });
+  });
+
+  it("setState reading a path mutated by an earlier setState sees fresh value", async () => {
+    probeGetSnapshot = undefined;
+    const spec: Spec = {
+      state: { counter: 0, counterCopy: -1 },
+      root: "main",
+      elements: {
+        main: {
+          type: "Button",
+          props: { label: "Go" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/counter", value: 42 },
+              },
+              {
+                action: "setState",
+                params: {
+                  statePath: "/counterCopy",
+                  value: { $state: "/counter" },
+                },
+              },
+            ],
+          },
+        },
+      },
+    };
+
+    function App() {
+      return (
+        <JSONUIProvider registry={registry} initialState={spec.state}>
+          <Renderer spec={spec} registry={registry} />
+          <StateProbe />
+        </JSONUIProvider>
+      );
+    }
+
+    render(() => <App />);
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getProbeState();
+      expect(state.counter).toBe(42);
+      expect(state.counterCopy).toBe(42);
+    });
+  });
+});

+ 346 - 0
packages/solid/src/contexts/actions.test.tsx

@@ -0,0 +1,346 @@
+import { describe, it, expect, vi } from "vitest";
+import { render } from "@solidjs/testing-library";
+import type { JSX } from "solid-js";
+import { StateProvider, useStateStore } from "./state";
+import {
+  ValidationProvider,
+  useValidation,
+  useFieldValidation,
+} from "./validation";
+import { ActionProvider, useActions, useAction } from "./actions";
+
+/**
+ * Build the full provider tree inline so that Solid's eager JSX evaluation
+ * always runs ActionProvider/ValidationProvider INSIDE StateProvider's scope.
+ *
+ * IMPORTANT: Do NOT pre-build JSX fragments as variables — Solid evaluates
+ * JSX eagerly, so `<ActionProvider>` would call `useStateStore()` before the
+ * StateProvider context is available.
+ */
+function withProviders<T>(
+  hook: () => T,
+  options: {
+    handlers?: Record<
+      string,
+      (params: Record<string, unknown>) => Promise<void> | void
+    >;
+    initialState?: Record<string, unknown>;
+    withValidation?: boolean;
+  } = {},
+): T {
+  let result!: T;
+  function TestComponent(): JSX.Element {
+    result = hook();
+    return (<div />) as JSX.Element;
+  }
+
+  if (options.withValidation) {
+    render(() => (
+      <StateProvider initialState={options.initialState ?? {}}>
+        <ValidationProvider>
+          <ActionProvider handlers={options.handlers}>
+            <TestComponent />
+          </ActionProvider>
+        </ValidationProvider>
+      </StateProvider>
+    ));
+  } else {
+    render(() => (
+      <StateProvider initialState={options.initialState ?? {}}>
+        <ActionProvider handlers={options.handlers}>
+          <TestComponent />
+        </ActionProvider>
+      </StateProvider>
+    ));
+  }
+  return result;
+}
+
+function withFullProviders(options: {
+  handlers?: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<void> | void
+  >;
+  initialState?: Record<string, unknown>;
+  withValidation?: boolean;
+}): {
+  stateCtx: ReturnType<typeof useStateStore>;
+  actionsCtx: ReturnType<typeof useActions>;
+  validationCtx?: ReturnType<typeof useValidation>;
+} {
+  let stateCtx!: ReturnType<typeof useStateStore>;
+  let actionsCtx!: ReturnType<typeof useActions>;
+  let validationCtx: ReturnType<typeof useValidation> | undefined;
+  function TestComponent(): JSX.Element {
+    stateCtx = useStateStore();
+    actionsCtx = useActions();
+    if (options.withValidation) {
+      validationCtx = useValidation();
+    }
+    return (<div />) as JSX.Element;
+  }
+
+  if (options.withValidation) {
+    render(() => (
+      <StateProvider initialState={options.initialState ?? {}}>
+        <ValidationProvider>
+          <ActionProvider handlers={options.handlers}>
+            <TestComponent />
+          </ActionProvider>
+        </ValidationProvider>
+      </StateProvider>
+    ));
+  } else {
+    render(() => (
+      <StateProvider initialState={options.initialState ?? {}}>
+        <ActionProvider handlers={options.handlers}>
+          <TestComponent />
+        </ActionProvider>
+      </StateProvider>
+    ));
+  }
+  return { stateCtx, actionsCtx, validationCtx };
+}
+
+describe("ActionProvider — provide/inject", () => {
+  it("useActions() throws outside a provider", () => {
+    expect(() => useActions()).toThrow(
+      "useActions must be used within an ActionProvider",
+    );
+  });
+});
+
+describe("ActionProvider — built-in setState", () => {
+  it("executes setState and updates state", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: { count: 0 },
+    });
+
+    await actionsCtx.execute({
+      action: "setState",
+      params: { statePath: "/count", value: 5 },
+    });
+
+    expect(stateCtx.get("/count")).toBe(5);
+  });
+});
+
+describe("ActionProvider — built-in pushState", () => {
+  it("appends to existing array", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: { items: ["a", "b"] },
+    });
+
+    await actionsCtx.execute({
+      action: "pushState",
+      params: { statePath: "/items", value: "c" },
+    });
+
+    expect(stateCtx.get("/items")).toEqual(["a", "b", "c"]);
+  });
+
+  it("creates array if path does not exist", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: {},
+    });
+
+    await actionsCtx.execute({
+      action: "pushState",
+      params: { statePath: "/newList", value: "first" },
+    });
+
+    expect(stateCtx.get("/newList")).toEqual(["first"]);
+  });
+});
+
+describe("ActionProvider — built-in removeState", () => {
+  it("removes item by index", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: { items: ["a", "b", "c"] },
+    });
+
+    await actionsCtx.execute({
+      action: "removeState",
+      params: { statePath: "/items", index: 1 },
+    });
+
+    expect(stateCtx.get("/items")).toEqual(["a", "c"]);
+  });
+});
+
+describe("ActionProvider — built-in push/pop navigation", () => {
+  it("push sets /currentScreen and /navStack", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: { currentScreen: "home" },
+    });
+
+    await actionsCtx.execute({
+      action: "push",
+      params: { screen: "settings" },
+    });
+
+    expect(stateCtx.get("/currentScreen")).toBe("settings");
+    expect(stateCtx.get("/navStack")).toEqual(["home"]);
+  });
+
+  it("pop restores previous screen from /navStack", async () => {
+    const { stateCtx, actionsCtx } = withFullProviders({
+      initialState: { currentScreen: "settings", navStack: ["home"] },
+    });
+
+    await actionsCtx.execute({ action: "pop" });
+
+    expect(stateCtx.get("/currentScreen")).toBe("home");
+    expect(stateCtx.get("/navStack")).toEqual([]);
+  });
+});
+
+describe("ActionProvider — custom handlers", () => {
+  it("executes custom handler with params", async () => {
+    const customHandler = vi.fn().mockResolvedValue(undefined);
+    const { actionsCtx } = withFullProviders({
+      handlers: { myAction: customHandler },
+    });
+
+    await actionsCtx.execute({
+      action: "myAction",
+      params: { foo: "bar" },
+    });
+
+    expect(customHandler).toHaveBeenCalledWith({ foo: "bar" });
+  });
+
+  it("console.warn for unknown action", async () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const { actionsCtx } = withFullProviders({});
+
+    await actionsCtx.execute({ action: "unknownAction" });
+
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringContaining("unknownAction"),
+    );
+    warnSpy.mockRestore();
+  });
+
+  it("tracks loading state during async action execution", async () => {
+    let resolveHandler!: () => void;
+    const slowHandler = vi.fn(
+      () =>
+        new Promise<void>((resolve) => {
+          resolveHandler = resolve;
+        }),
+    );
+
+    let executeFn!: ReturnType<typeof useActions>["execute"];
+
+    const { findByTestId } = render(() => {
+      function Inner(): JSX.Element {
+        const actions = useActions();
+        executeFn = actions.execute;
+        return (
+          <span data-testid="loading">
+            {actions.loadingActions.has("slowAction") ? "true" : "false"}
+          </span>
+        );
+      }
+      return (
+        <StateProvider initialState={{}}>
+          <ActionProvider handlers={{ slowAction: slowHandler }}>
+            <Inner />
+          </ActionProvider>
+        </StateProvider>
+      );
+    });
+
+    // Before execution — not loading
+    const el = await findByTestId("loading");
+    expect(el.textContent).toBe("false");
+
+    const executePromise = executeFn({ action: "slowAction" });
+
+    // During execution — loading (need to wait for Solid's reactivity to flush)
+    await vi.waitFor(() => {
+      expect(el.textContent).toBe("true");
+    });
+
+    resolveHandler();
+    await executePromise;
+
+    // After execution — no longer loading
+    await vi.waitFor(() => {
+      expect(el.textContent).toBe("false");
+    });
+  });
+
+  it("registerHandler allows dynamic handler registration", async () => {
+    const dynamicHandler = vi.fn().mockResolvedValue(undefined);
+    const { actionsCtx } = withFullProviders({});
+
+    actionsCtx.registerHandler("dynamicAction", dynamicHandler);
+    await actionsCtx.execute({
+      action: "dynamicAction",
+      params: { x: 1 },
+    });
+
+    expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 });
+  });
+
+  it("handler receives resolved params object", async () => {
+    const handler = vi.fn().mockResolvedValue(undefined);
+    const { actionsCtx } = withFullProviders({
+      handlers: { myAction: handler },
+    });
+
+    await actionsCtx.execute({
+      action: "myAction",
+      params: { x: 1, y: "hello" },
+    });
+
+    expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
+  });
+});
+
+describe("ActionProvider — validateForm", () => {
+  it("writes { valid, errors } to state", async () => {
+    const { stateCtx, actionsCtx, validationCtx } = withFullProviders({
+      initialState: {},
+      withValidation: true,
+    });
+
+    validationCtx!.registerField("/form/email", {
+      checks: [{ type: "required", message: "Required" }],
+    });
+
+    await actionsCtx.execute({ action: "validateForm" });
+
+    expect(stateCtx.get("/formValidation")).toEqual({
+      valid: false,
+      errors: { "/form/email": ["Required"] },
+    });
+  });
+
+  it("warns without ValidationProvider", async () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const { actionsCtx } = withFullProviders({
+      withValidation: false,
+    });
+
+    await actionsCtx.execute({ action: "validateForm" });
+
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringContaining("validateForm action was dispatched"),
+    );
+    warnSpy.mockRestore();
+  });
+});
+
+describe("useAction", () => {
+  it("returns { execute, isLoading: false } before execution", () => {
+    const result = withProviders(() => useAction({ action: "myAction" }), {
+      handlers: { myAction: vi.fn() },
+    });
+
+    expect(typeof result.execute).toBe("function");
+    expect(result.isLoading).toBe(false);
+  });
+});

+ 400 - 0
packages/solid/src/contexts/actions.tsx

@@ -0,0 +1,400 @@
+import {
+  createContext,
+  useContext,
+  createSignal,
+  type ParentProps,
+  type JSX,
+} from "solid-js";
+import {
+  resolveAction,
+  executeAction,
+  type ActionBinding,
+  type ActionHandler,
+  type ActionConfirm,
+  type ResolvedAction,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+import { useOptionalValidation } from "./validation";
+
+let idCounter = 0;
+function generateUniqueId(): string {
+  idCounter += 1;
+  return `${Date.now()}-${idCounter}`;
+}
+
+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;
+}
+
+export interface PendingConfirmation {
+  action: ResolvedAction;
+  handler: ActionHandler;
+  resolve: () => void;
+  reject: () => void;
+}
+
+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 ActionContext = createContext<ActionContextValue | null>(null);
+
+export interface ActionProviderProps {
+  handlers?: Record<string, ActionHandler>;
+  navigate?: (path: string) => void;
+}
+
+export function ActionProvider(props: ParentProps<ActionProviderProps>) {
+  const { get, set, getSnapshot } = useStateStore();
+  const validation = useOptionalValidation();
+
+  const [handlers, setHandlers] = createSignal<Record<string, ActionHandler>>(
+    props.handlers ?? {},
+  );
+  const [loadingActions, setLoadingActions] = createSignal<Set<string>>(
+    new Set(),
+  );
+  const [pendingConfirmation, setPendingConfirmation] =
+    createSignal<PendingConfirmation | null>(null);
+
+  const registerHandler = (name: string, handler: ActionHandler) => {
+    setHandlers((prev) => ({ ...prev, [name]: handler }));
+  };
+
+  const execute = async (binding: ActionBinding) => {
+    const resolved = resolveAction(binding, getSnapshot());
+
+    if (resolved.action === "setState" && resolved.params) {
+      const statePath = resolved.params.statePath as string;
+      const value = resolved.params.value;
+      if (statePath) {
+        set(statePath, value);
+      }
+      return;
+    }
+
+    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;
+    }
+
+    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;
+    }
+
+    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;
+    }
+
+    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;
+    }
+
+    if (resolved.action === "validateForm") {
+      const validateAll = validation?.validateAll;
+      if (!validateAll) {
+        console.warn(
+          "validateForm action was dispatched but no ValidationProvider is connected. " +
+            "Ensure ValidationProvider is rendered inside the provider tree.",
+        );
+        return;
+      }
+      const valid = validateAll();
+      const errors: Record<string, string[]> = {};
+      for (const [path, fs] of Object.entries(validation.fieldStates)) {
+        if (fs.result && !fs.result.valid) {
+          errors[path] = fs.result.errors;
+        }
+      }
+      const statePath =
+        (resolved.params?.statePath as string) || "/formValidation";
+      set(statePath, { valid, errors });
+      return;
+    }
+
+    const handler = handlers()[resolved.action];
+
+    if (!handler) {
+      console.warn(`No handler registered for action: ${resolved.action}`);
+      return;
+    }
+
+    if (resolved.confirm) {
+      return new Promise<void>((resolve, reject) => {
+        setPendingConfirmation({
+          action: resolved,
+          handler,
+          resolve: () => {
+            setPendingConfirmation(null);
+            resolve();
+          },
+          reject: () => {
+            setPendingConfirmation(null);
+            reject(new Error("Action cancelled"));
+          },
+        });
+      }).then(async () => {
+        setLoadingActions((prev) => new Set(prev).add(resolved.action));
+        try {
+          await executeAction({
+            action: resolved,
+            handler,
+            setState: set,
+            navigate: props.navigate,
+            executeAction: async (name: string) => {
+              const subBinding: ActionBinding = { action: name };
+              await execute(subBinding);
+            },
+          });
+        } finally {
+          setLoadingActions((prev) => {
+            const next = new Set(prev);
+            next.delete(resolved.action);
+            return next;
+          });
+        }
+      });
+    }
+
+    setLoadingActions((prev) => new Set(prev).add(resolved.action));
+    try {
+      await executeAction({
+        action: resolved,
+        handler,
+        setState: set,
+        navigate: props.navigate,
+        executeAction: async (name: string) => {
+          const subBinding: ActionBinding = { action: name };
+          await execute(subBinding);
+        },
+      });
+    } finally {
+      setLoadingActions((prev) => {
+        const next = new Set(prev);
+        next.delete(resolved.action);
+        return next;
+      });
+    }
+  };
+
+  const confirm = () => {
+    pendingConfirmation()?.resolve();
+  };
+
+  const cancel = () => {
+    pendingConfirmation()?.reject();
+  };
+
+  const ctx: ActionContextValue = {
+    get handlers() {
+      return handlers();
+    },
+    get loadingActions() {
+      return loadingActions();
+    },
+    get pendingConfirmation() {
+      return pendingConfirmation();
+    },
+    execute,
+    confirm,
+    cancel,
+    registerHandler,
+  };
+
+  return (
+    <ActionContext.Provider value={ctx}>
+      {props.children}
+    </ActionContext.Provider>
+  );
+}
+
+export function useActions(): ActionContextValue {
+  const ctx = useContext(ActionContext);
+  if (!ctx) {
+    throw new Error("useActions must be used within an ActionProvider");
+  }
+  return ctx;
+}
+
+export function useAction(binding: ActionBinding): {
+  execute: () => Promise<void>;
+  isLoading: boolean;
+} {
+  const actions = useActions();
+  return {
+    execute: () => actions.execute(binding),
+    get isLoading() {
+      return actions.loadingActions.has(binding.action);
+    },
+  };
+}
+
+export interface ConfirmDialogProps {
+  confirm: ActionConfirm;
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+export function ConfirmDialog(props: ConfirmDialogProps): JSX.Element {
+  const isDanger = () => props.confirm.variant === "danger";
+
+  return (
+    <div
+      style={{
+        position: "fixed",
+        inset: "0",
+        "background-color": "rgba(0, 0, 0, 0.5)",
+        display: "flex",
+        "align-items": "center",
+        "justify-content": "center",
+        "z-index": 50,
+      }}
+      onClick={props.onCancel}
+    >
+      <div
+        style={{
+          "background-color": "white",
+          "border-radius": "8px",
+          padding: "24px",
+          "max-width": "400px",
+          width: "100%",
+          "box-shadow": "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
+        }}
+        onClick={(e) => e.stopPropagation()}
+      >
+        <h3
+          style={{
+            margin: "0 0 8px 0",
+            "font-size": "18px",
+            "font-weight": "600",
+          }}
+        >
+          {props.confirm.title}
+        </h3>
+        <p
+          style={{
+            margin: "0 0 24px 0",
+            color: "#6b7280",
+          }}
+        >
+          {props.confirm.message}
+        </p>
+        <div
+          style={{
+            display: "flex",
+            gap: "12px",
+            "justify-content": "flex-end",
+          }}
+        >
+          <button
+            onClick={props.onCancel}
+            style={{
+              padding: "8px 16px",
+              "border-radius": "6px",
+              border: "1px solid #d1d5db",
+              "background-color": "white",
+              cursor: "pointer",
+            }}
+          >
+            {props.confirm.cancelLabel ?? "Cancel"}
+          </button>
+          <button
+            onClick={props.onConfirm}
+            style={{
+              padding: "8px 16px",
+              "border-radius": "6px",
+              border: "none",
+              "background-color": isDanger() ? "#dc2626" : "#3b82f6",
+              color: "white",
+              cursor: "pointer",
+            }}
+          >
+            {props.confirm.confirmLabel ?? "Confirm"}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 23 - 0
packages/solid/src/contexts/repeat-scope.tsx

@@ -0,0 +1,23 @@
+import { createContext, useContext, type ParentProps } from "solid-js";
+
+export interface RepeatScopeValue {
+  item: unknown;
+  index: number;
+  basePath: string;
+}
+
+const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
+
+export function RepeatScopeProvider(props: ParentProps<RepeatScopeValue>) {
+  return (
+    <RepeatScopeContext.Provider
+      value={{ item: props.item, index: props.index, basePath: props.basePath }}
+    >
+      {props.children}
+    </RepeatScopeContext.Provider>
+  );
+}
+
+export function useRepeatScope(): RepeatScopeValue | null {
+  return useContext(RepeatScopeContext);
+}

+ 215 - 0
packages/solid/src/contexts/state.test.tsx

@@ -0,0 +1,215 @@
+import { describe, it, expect, vi } from "vitest";
+import { render } from "@solidjs/testing-library";
+import { createEffect, createSignal, type Accessor, type JSX } from "solid-js";
+import { createStateStore } from "@json-render/core";
+import {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+} from "./state";
+
+function renderWithState<T>(
+  hook: () => T,
+  initialState: Record<string, unknown> = {},
+  extraProps?: { store?: any; onStateChange?: any },
+): T {
+  let result!: T;
+  function TestComponent(): JSX.Element {
+    result = hook();
+    return (<div data-testid="test" />) as unknown as JSX.Element;
+  }
+  render(() => (
+    <StateProvider initialState={initialState} {...(extraProps ?? {})}>
+      <TestComponent />
+    </StateProvider>
+  ));
+  return result;
+}
+
+describe("StateProvider + hooks", () => {
+  it("useStateStore() throws outside provider", () => {
+    expect(() => useStateStore()).toThrow(
+      "useStateStore must be used within a StateProvider",
+    );
+  });
+
+  it("StateProvider + useStateStore round-trip (get + set)", () => {
+    const ctx = renderWithState(() => useStateStore(), { count: 0 });
+
+    expect(ctx.get("/count")).toBe(0);
+    ctx.set("/count", 42);
+    expect(ctx.get("/count")).toBe(42);
+  });
+
+  it("useStateValue reads from state", () => {
+    const value = renderWithState(() => useStateValue("/name"), {
+      name: "Alice",
+    });
+
+    expect(value()).toBe("Alice");
+  });
+
+  it("useStateBinding returns value and setter", () => {
+    const [value, setValue] = renderWithState(() => useStateBinding("/x"), {
+      x: 1,
+    });
+
+    expect(value()).toBe(1);
+    expect(typeof setValue).toBe("function");
+  });
+
+  it("useStateValue stays reactive after state updates", () => {
+    const observed: Array<string | undefined> = [];
+
+    function TestComponent(): JSX.Element {
+      const value = useStateValue<string>("/name");
+      createEffect(() => {
+        observed.push(value());
+      });
+      const store = useStateStore();
+      return (
+        <button
+          data-testid="update"
+          onClick={() => store.set("/name", "Bob")}
+        />
+      ) as unknown as JSX.Element;
+    }
+
+    const { getByTestId } = render(() => (
+      <StateProvider initialState={{ name: "Alice" }}>
+        <TestComponent />
+      </StateProvider>
+    ));
+
+    expect(observed).toEqual(["Alice"]);
+    getByTestId("update").click();
+    expect(observed).toEqual(["Alice", "Bob"]);
+  });
+
+  it("useStateBinding value accessor stays reactive after state updates", () => {
+    const observed: Array<number | undefined> = [];
+
+    function TestComponent(): JSX.Element {
+      const [value, setValue] = useStateBinding<number>("/count");
+      createEffect(() => {
+        observed.push(value());
+      });
+      return (
+        <button data-testid="update" onClick={() => setValue(2)} />
+      ) as unknown as JSX.Element;
+    }
+
+    const { getByTestId } = render(() => (
+      <StateProvider initialState={{ count: 1 }}>
+        <TestComponent />
+      </StateProvider>
+    ));
+
+    expect(observed).toEqual([1]);
+    getByTestId("update").click();
+    expect(observed).toEqual([1, 2]);
+  });
+
+  it("provides initial state to consumers", () => {
+    const ctx = renderWithState(() => useStateStore(), { a: 1, b: "hello" });
+
+    expect(ctx.state).toEqual({ a: 1, b: "hello" });
+  });
+
+  it("provides empty object when no initial state", () => {
+    const ctx = renderWithState(() => useStateStore());
+
+    expect(ctx.state).toEqual({});
+  });
+
+  it("get() retrieves values by path", () => {
+    const ctx = renderWithState(() => useStateStore(), {
+      user: { name: "Bob" },
+    });
+
+    expect(ctx.get("/user/name")).toBe("Bob");
+  });
+
+  it("get() returns undefined for missing path", () => {
+    const ctx = renderWithState(() => useStateStore(), {
+      user: { name: "Bob" },
+    });
+
+    expect(ctx.get("/user/age")).toBeUndefined();
+  });
+
+  it("set() updates values at path", () => {
+    const ctx = renderWithState(() => useStateStore(), { x: 0 });
+
+    ctx.set("/x", 42);
+    expect(ctx.get("/x")).toBe(42);
+  });
+
+  it("set() creates nested paths", () => {
+    const ctx = renderWithState(() => useStateStore());
+
+    ctx.set("/a/b/c", "deep");
+    expect(ctx.get("/a/b/c")).toBe("deep");
+  });
+
+  it("set() calls onStateChange callback", () => {
+    const onChange = vi.fn();
+    const ctx = renderWithState(
+      () => useStateStore(),
+      {},
+      { onStateChange: onChange },
+    );
+
+    ctx.set("/name", "Alice");
+    expect(onChange).toHaveBeenCalledOnce();
+    expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
+  });
+
+  it("update() handles multiple values at once", () => {
+    const ctx = renderWithState(() => useStateStore());
+
+    ctx.update({ "/a": 1, "/b": "hello" });
+    expect(ctx.get("/a")).toBe(1);
+    expect(ctx.get("/b")).toBe("hello");
+  });
+
+  it("update() calls onStateChange with all changes", () => {
+    const onChange = vi.fn();
+    const ctx = renderWithState(
+      () => useStateStore(),
+      {},
+      { onStateChange: onChange },
+    );
+
+    ctx.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 },
+      ]),
+    );
+  });
+
+  it("handles deeply nested state paths", () => {
+    const ctx = renderWithState(() => useStateStore());
+
+    ctx.set("/a/b/c/d", "nested");
+    expect(ctx.get("/a/b/c/d")).toBe("nested");
+  });
+
+  it("controlled mode: reads/writes through external StateStore", () => {
+    const store = createStateStore({ x: 10 });
+    const ctx = renderWithState(() => useStateStore(), {}, { store });
+
+    expect(ctx.get("/x")).toBe(10);
+
+    ctx.set("/x", 99);
+    expect(store.getSnapshot()).toEqual({ x: 99 });
+
+    store.set("/x", 200);
+    expect(store.get("/x")).toBe(200);
+  });
+});

+ 208 - 0
packages/solid/src/contexts/state.tsx

@@ -0,0 +1,208 @@
+import {
+  createContext,
+  useContext,
+  createSignal,
+  createEffect,
+  createMemo,
+  onCleanup,
+  type Accessor,
+  type ParentProps,
+} from "solid-js";
+import {
+  getByPath,
+  createStateStore,
+  type StateModel,
+  type StateStore,
+} from "@json-render/core";
+import { flattenToPointers } from "@json-render/core/store-utils";
+
+export interface StateContextValue {
+  state: StateModel;
+  get: (path: string) => unknown;
+  set: (path: string, value: unknown) => void;
+  update: (updates: Record<string, unknown>) => void;
+  getSnapshot: () => StateModel;
+  subscribeChanges: (
+    listener: (changes: Array<{ path: string; value: unknown }>) => void,
+  ) => () => void;
+}
+
+const StateContext = createContext<StateContextValue | null>(null);
+
+export interface StateProviderProps {
+  store?: StateStore;
+  initialState?: StateModel;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+}
+
+function computeInitialFlat(
+  isControlled: boolean,
+  initialState: StateModel,
+): Record<string, unknown> | null {
+  if (isControlled) return null;
+  if (Object.keys(initialState).length === 0) return {};
+  return flattenToPointers(initialState);
+}
+
+export function StateProvider(props: ParentProps<StateProviderProps>) {
+  let internalStore: StateStore | undefined;
+  if (!props.store) {
+    internalStore = createStateStore(props.initialState ?? {});
+  }
+
+  const store = () => props.store ?? internalStore!;
+
+  const initialMode = props.store ? "controlled" : "uncontrolled";
+  let modeWarned = false;
+
+  if (
+    typeof globalThis !== "undefined" &&
+    (globalThis as any).process?.env?.NODE_ENV !== "production"
+  ) {
+    createEffect(() => {
+      const currentMode = props.store ? "controlled" : "uncontrolled";
+      if (currentMode !== initialMode && !modeWarned) {
+        modeWarned = true;
+        console.warn(
+          `StateProvider: switching from ${initialMode} to ${currentMode} mode is not supported.`,
+        );
+      }
+    });
+  }
+
+  let prevInitialState = props.initialState;
+  let prevFlat: Record<string, unknown> | null = computeInitialFlat(
+    !!props.store,
+    props.initialState ?? {},
+  );
+
+  createEffect(() => {
+    if (props.store) return;
+    const initialState = props.initialState ?? {};
+    if (initialState === prevInitialState) return;
+    prevInitialState = initialState;
+    const nextFlat =
+      initialState && Object.keys(initialState).length > 0
+        ? flattenToPointers(initialState)
+        : {};
+    const prevFlatObj = prevFlat ?? {};
+    const allKeys = new Set([
+      ...Object.keys(prevFlatObj),
+      ...Object.keys(nextFlat),
+    ]);
+    const updates: Record<string, unknown> = {};
+    for (const key of allKeys) {
+      if (prevFlatObj[key] !== nextFlat[key]) {
+        updates[key] = key in nextFlat ? nextFlat[key] : undefined;
+      }
+    }
+    prevFlat = nextFlat;
+    if (Object.keys(updates).length > 0) {
+      store().update(updates);
+    }
+  });
+
+  const [state, setState] = createSignal<StateModel>(store().getSnapshot(), {
+    equals: false,
+  });
+  const changeListeners = new Set<
+    (changes: Array<{ path: string; value: unknown }>) => void
+  >();
+
+  const subscribeChanges = (
+    listener: (changes: Array<{ path: string; value: unknown }>) => void,
+  ) => {
+    changeListeners.add(listener);
+    return () => {
+      changeListeners.delete(listener);
+    };
+  };
+
+  const notifyChanges = (changes: Array<{ path: string; value: unknown }>) => {
+    for (const listener of changeListeners) {
+      listener(changes);
+    }
+  };
+
+  createEffect(() => {
+    const s = store();
+    setState(s.getSnapshot());
+    const unsubscribe = s.subscribe(() => {
+      setState(s.getSnapshot());
+    });
+    onCleanup(unsubscribe);
+  });
+
+  const set = (path: string, value: unknown) => {
+    const s = store();
+    const prev = s.getSnapshot();
+    const prevValue = getByPath(prev, path);
+    s.set(path, value);
+    if (prevValue !== value) {
+      const changes = [{ path, value }];
+      notifyChanges(changes);
+      if (!props.store && s.getSnapshot() !== prev) {
+        props.onStateChange?.(changes);
+      }
+    }
+  };
+
+  const update = (updates: Record<string, unknown>) => {
+    const s = store();
+    const prev = s.getSnapshot();
+    s.update(updates);
+    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) {
+      notifyChanges(changes);
+      if (!props.store && s.getSnapshot() !== prev) {
+        props.onStateChange?.(changes);
+      }
+    }
+  };
+
+  const get = (path: string) => store().get(path);
+
+  const getSnapshot = () => store().getSnapshot();
+
+  const ctx: StateContextValue = {
+    get state() {
+      return state();
+    },
+    get,
+    set,
+    update,
+    getSnapshot,
+    subscribeChanges,
+  };
+
+  return (
+    <StateContext.Provider value={ctx}>{props.children}</StateContext.Provider>
+  );
+}
+
+export function useStateStore(): StateContextValue {
+  const ctx = useContext(StateContext);
+  if (!ctx) {
+    throw new Error("useStateStore must be used within a StateProvider");
+  }
+  return ctx;
+}
+
+export function useStateValue<T>(path: string): Accessor<T | undefined> {
+  const store = useStateStore();
+  return createMemo(() => getByPath(store.state, path) as T | undefined);
+}
+
+export function useStateBinding<T>(
+  path: string,
+): [Accessor<T | undefined>, (value: T) => void] {
+  const store = useStateStore();
+  const value = createMemo(() => getByPath(store.state, path) as T | undefined);
+  const setValue = (newValue: T) => store.set(path, newValue);
+  return [value, setValue];
+}

+ 194 - 0
packages/solid/src/contexts/validation.test.tsx

@@ -0,0 +1,194 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@solidjs/testing-library";
+import { createEffect, type JSX } from "solid-js";
+import { StateProvider } from "./state";
+import {
+  ValidationProvider,
+  useValidation,
+  useOptionalValidation,
+  useFieldValidation,
+} from "./validation";
+
+function withProviders<T>(
+  hook: () => T,
+  initialState: Record<string, unknown> = {},
+): T {
+  let result!: T;
+  function TestComponent(): JSX.Element {
+    result = hook();
+    return (<div />) as JSX.Element;
+  }
+  render(() => (
+    <StateProvider initialState={initialState}>
+      <ValidationProvider>
+        <TestComponent />
+      </ValidationProvider>
+    </StateProvider>
+  ));
+  return result;
+}
+
+function withStateOnly<T>(hook: () => T): T {
+  let result!: T;
+  function TestComponent(): JSX.Element {
+    result = hook();
+    return (<div />) as JSX.Element;
+  }
+  render(() => (
+    <StateProvider initialState={{}}>
+      <TestComponent />
+    </StateProvider>
+  ));
+  return result;
+}
+
+function withBothContexts(
+  initialState: Record<string, unknown> = {},
+  fieldPath: string = "/name",
+  config?: { checks: Array<{ type: string; message: string }> },
+) {
+  let validationCtx!: ReturnType<typeof useValidation>;
+  let fieldCtx!: ReturnType<typeof useFieldValidation>;
+  function TestComponent(): JSX.Element {
+    validationCtx = useValidation();
+    fieldCtx = useFieldValidation(fieldPath, config);
+    return (<div />) as JSX.Element;
+  }
+  render(() => (
+    <StateProvider initialState={initialState}>
+      <ValidationProvider>
+        <TestComponent />
+      </ValidationProvider>
+    </StateProvider>
+  ));
+  return { validationCtx, fieldCtx };
+}
+
+describe("ValidationProvider — provide/inject", () => {
+  it("useValidation() throws outside a provider", () => {
+    expect(() => useValidation()).toThrow(
+      "useValidation must be used within a ValidationProvider",
+    );
+  });
+});
+
+describe("useOptionalValidation", () => {
+  it("returns null outside ValidationProvider", () => {
+    const result = withStateOnly(() => useOptionalValidation());
+    expect(result).toBeNull();
+  });
+
+  it("returns context inside 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("validate() with empty required field returns valid:false and errors", () => {
+    const { fieldCtx } = withBothContexts({ name: "" }, "/name", {
+      checks: [{ type: "required", message: "Name is required" }],
+    });
+
+    const result = fieldCtx.validate();
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain("Name is required");
+    expect(fieldCtx.errors()).toContain("Name is required");
+    expect(fieldCtx.isValid()).toBe(false);
+  });
+
+  it("validate() with valid value returns valid:true and no errors", () => {
+    const { fieldCtx } = withBothContexts({ name: "Alice" }, "/name", {
+      checks: [{ type: "required", message: "Name is required" }],
+    });
+
+    const result = fieldCtx.validate();
+    expect(result.valid).toBe(true);
+    expect(result.errors).toHaveLength(0);
+    expect(fieldCtx.errors()).toHaveLength(0);
+    expect(fieldCtx.isValid()).toBe(true);
+  });
+
+  it("touch() sets touched:true in fieldStates", () => {
+    const { validationCtx, fieldCtx } = withBothContexts({}, "/email");
+
+    fieldCtx.touch();
+    expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
+  });
+
+  it("clear() resets field state from validation context", () => {
+    const { validationCtx, fieldCtx } = withBothContexts(
+      { email: "" },
+      "/email",
+      { checks: [{ type: "required", message: "Required" }] },
+    );
+
+    fieldCtx.validate();
+    fieldCtx.clear();
+    expect(validationCtx.fieldStates["/email"]).toBeUndefined();
+  });
+
+  it("useFieldValidation accessors stay reactive after validate/touch", () => {
+    const observedErrors: string[][] = [];
+    const observedTouched: boolean[] = [];
+
+    function TestComponent(): JSX.Element {
+      const field = useFieldValidation("/email", {
+        checks: [{ type: "required", message: "Required" }],
+      });
+
+      createEffect(() => {
+        observedErrors.push(field.errors());
+      });
+
+      createEffect(() => {
+        observedTouched.push(field.state().touched);
+      });
+
+      return (
+        <>
+          <button data-testid="validate" onClick={() => field.validate()} />
+          <button data-testid="touch" onClick={() => field.touch()} />
+        </>
+      ) as JSX.Element;
+    }
+
+    const { getByTestId } = render(() => (
+      <StateProvider initialState={{ email: "" }}>
+        <ValidationProvider>
+          <TestComponent />
+        </ValidationProvider>
+      </StateProvider>
+    ));
+
+    expect(observedErrors).toEqual([[]]);
+    expect(observedTouched).toEqual([false]);
+
+    getByTestId("validate").click();
+    expect(observedErrors).toEqual([[], ["Required"]]);
+    expect(observedTouched).toEqual([false, true]);
+
+    getByTestId("touch").click();
+    expect(observedTouched).toEqual([false, true, true]);
+  });
+});
+
+describe("validateAll", () => {
+  it("returns true when all registered fields pass", () => {
+    const { validationCtx } = withBothContexts({ name: "Alice" }, "/name", {
+      checks: [{ type: "required", message: "Required" }],
+    });
+
+    expect(validationCtx.validateAll()).toBe(true);
+  });
+
+  it("returns false when any field fails", () => {
+    const { validationCtx } = withBothContexts({ name: "" }, "/name", {
+      checks: [{ type: "required", message: "Required" }],
+    });
+
+    expect(validationCtx.validateAll()).toBe(false);
+  });
+});

+ 263 - 0
packages/solid/src/contexts/validation.tsx

@@ -0,0 +1,263 @@
+import {
+  createContext,
+  useContext,
+  createSignal,
+  createEffect,
+  createMemo,
+  type Accessor,
+  type ParentProps,
+} from "solid-js";
+import {
+  runValidation,
+  type ValidationConfig,
+  type ValidationFunction,
+  type ValidationResult,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface FieldValidationState {
+  touched: boolean;
+  validated: boolean;
+  result: ValidationResult | null;
+}
+
+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 ValidationContext = createContext<ValidationContextValue | null>(null);
+
+export interface ValidationProviderProps {
+  customFunctions?: Record<string, ValidationFunction>;
+}
+
+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;
+}
+
+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;
+}
+
+export function ValidationProvider(
+  props: ParentProps<ValidationProviderProps>,
+) {
+  const { getSnapshot } = useStateStore();
+  const customFunctions = () => props.customFunctions ?? {};
+
+  const [fieldStates, setFieldStates] = createSignal<
+    Record<string, FieldValidationState>
+  >({});
+  let fieldStatesRef: Record<string, FieldValidationState> = {};
+
+  const [fieldConfigs, setFieldConfigs] = createSignal<
+    Record<string, ValidationConfig>
+  >({});
+
+  const registerField = (path: string, config: ValidationConfig) => {
+    setFieldConfigs((prev) => {
+      const existing = prev[path];
+      if (existing && validationConfigEqual(existing, config)) {
+        return prev;
+      }
+      return { ...prev, [path]: config };
+    });
+  };
+
+  const validate = (
+    path: string,
+    config: ValidationConfig,
+  ): ValidationResult => {
+    const currentState = getSnapshot();
+    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: customFunctions(),
+    });
+
+    const newFieldState: FieldValidationState = {
+      touched: fieldStatesRef[path]?.touched ?? true,
+      validated: true,
+      result,
+    };
+    fieldStatesRef = {
+      ...fieldStatesRef,
+      [path]: newFieldState,
+    };
+    setFieldStates(fieldStatesRef);
+
+    return result;
+  };
+
+  const touch = (path: string) => {
+    fieldStatesRef = {
+      ...fieldStatesRef,
+      [path]: {
+        ...fieldStatesRef[path],
+        touched: true,
+        validated: fieldStatesRef[path]?.validated ?? false,
+        result: fieldStatesRef[path]?.result ?? null,
+      },
+    };
+    setFieldStates(fieldStatesRef);
+  };
+
+  const clear = (path: string) => {
+    const { [path]: _, ...rest } = fieldStatesRef;
+    fieldStatesRef = rest;
+    setFieldStates(rest);
+  };
+
+  const validateAll = () => {
+    let allValid = true;
+
+    for (const [path, config] of Object.entries(fieldConfigs())) {
+      const result = validate(path, config);
+      if (!result.valid) {
+        allValid = false;
+      }
+    }
+
+    return allValid;
+  };
+
+  const value: ValidationContextValue = {
+    get customFunctions() {
+      return customFunctions();
+    },
+    get fieldStates() {
+      fieldStates();
+      return fieldStatesRef;
+    },
+    validate,
+    touch,
+    clear,
+    validateAll,
+    registerField,
+  };
+
+  return (
+    <ValidationContext.Provider value={value}>
+      {props.children}
+    </ValidationContext.Provider>
+  );
+}
+
+export function useValidation(): ValidationContextValue {
+  const ctx = useContext(ValidationContext);
+  if (!ctx) {
+    throw new Error("useValidation must be used within a ValidationProvider");
+  }
+  return ctx;
+}
+
+export function useOptionalValidation(): ValidationContextValue | null {
+  return useContext(ValidationContext);
+}
+
+export function useFieldValidation(
+  path: string,
+  config?: ValidationConfig,
+): {
+  state: Accessor<FieldValidationState>;
+  validate: () => ValidationResult;
+  touch: () => void;
+  clear: () => void;
+  errors: Accessor<string[]>;
+  isValid: Accessor<boolean>;
+} {
+  const validation = useValidation();
+
+  createEffect(() => {
+    if (path && config) {
+      validation.registerField(path, config);
+    }
+  });
+
+  const state = createMemo<FieldValidationState>(() => {
+    const current = validation.fieldStates[path];
+    return (
+      current ?? {
+        touched: false,
+        validated: false,
+        result: null,
+      }
+    );
+  });
+
+  const validate = () => validation.validate(path, config ?? { checks: [] });
+  const touch = () => validation.touch(path);
+  const clear = () => validation.clear(path);
+  const errors = createMemo(() => state().result?.errors ?? []);
+  const isValid = createMemo(() => state().result?.valid ?? true);
+
+  return {
+    state,
+    validate,
+    touch,
+    clear,
+    errors,
+    isValid,
+  };
+}

+ 94 - 0
packages/solid/src/contexts/visibility.test.tsx

@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@solidjs/testing-library";
+import type { JSX } from "solid-js";
+import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
+import { StateProvider } from "./state";
+
+function renderWithVisibility<T>(
+  hook: () => T,
+  data: Record<string, unknown> = {},
+): T {
+  let result!: T;
+  function TestComponent(): JSX.Element {
+    result = hook();
+    return (<div />) as unknown as JSX.Element;
+  }
+  render(() => (
+    <StateProvider initialState={data}>
+      <VisibilityProvider>
+        <TestComponent />
+      </VisibilityProvider>
+    </StateProvider>
+  ));
+  return result;
+}
+
+describe("useVisibility", () => {
+  it("provides isVisible function", () => {
+    const ctx = renderWithVisibility(() => useVisibility());
+
+    expect(typeof ctx.isVisible).toBe("function");
+  });
+
+  it("provides visibility context with stateModel", () => {
+    const ctx = renderWithVisibility(() => useVisibility(), { test: true });
+
+    expect(ctx.ctx.stateModel).toEqual({ test: true });
+  });
+});
+
+describe("useIsVisible", () => {
+  it("returns true for undefined condition", () => {
+    const result = renderWithVisibility(() => useIsVisible(undefined));
+
+    expect(result).toBe(true);
+  });
+
+  it("returns true for true condition", () => {
+    const result = renderWithVisibility(() => useIsVisible(true));
+
+    expect(result).toBe(true);
+  });
+
+  it("returns false for false condition", () => {
+    const result = renderWithVisibility(() => useIsVisible(false));
+
+    expect(result).toBe(false);
+  });
+
+  it("evaluates $state conditions against data", () => {
+    const trueResult = renderWithVisibility(
+      () => useIsVisible({ $state: "/isVisible" }),
+      { isVisible: true },
+    );
+    expect(trueResult).toBe(true);
+
+    const falseResult = renderWithVisibility(
+      () => useIsVisible({ $state: "/isVisible" }),
+      { isVisible: false },
+    );
+    expect(falseResult).toBe(false);
+  });
+
+  it("evaluates equality conditions", () => {
+    const result = renderWithVisibility(
+      () => useIsVisible({ $state: "/count", eq: 1 }),
+      { count: 1 },
+    );
+
+    expect(result).toBe(true);
+  });
+
+  it("evaluates array conditions (implicit AND)", () => {
+    const result = renderWithVisibility(
+      () =>
+        useIsVisible([
+          { $state: "/user/isAdmin" },
+          { $state: "/count", eq: 5 },
+        ]),
+      { user: { isAdmin: true }, count: 5 },
+    );
+
+    expect(result).toBe(true);
+  });
+});

+ 53 - 0
packages/solid/src/contexts/visibility.tsx

@@ -0,0 +1,53 @@
+import { createContext, useContext, type ParentProps } from "solid-js";
+import {
+  evaluateVisibility,
+  type VisibilityCondition,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface VisibilityContextValue {
+  isVisible: (condition: VisibilityCondition | undefined) => boolean;
+  ctx: CoreVisibilityContext;
+}
+
+const VisibilityContext = createContext<VisibilityContextValue | null>(null);
+
+export type VisibilityProviderProps = ParentProps;
+
+export function VisibilityProvider(props: VisibilityProviderProps) {
+  const stateStore = useStateStore();
+
+  const visibilityCtx: CoreVisibilityContext = {
+    get stateModel() {
+      return stateStore.state;
+    },
+  };
+
+  const value: VisibilityContextValue = {
+    isVisible: (condition: VisibilityCondition | undefined) =>
+      evaluateVisibility(condition, visibilityCtx),
+    ctx: visibilityCtx,
+  };
+
+  return (
+    <VisibilityContext.Provider value={value}>
+      {props.children}
+    </VisibilityContext.Provider>
+  );
+}
+
+export function useVisibility(): VisibilityContextValue {
+  const ctx = useContext(VisibilityContext);
+  if (!ctx) {
+    throw new Error("useVisibility must be used within a VisibilityProvider");
+  }
+  return ctx;
+}
+
+export function useIsVisible(
+  condition: VisibilityCondition | undefined,
+): boolean {
+  const { isVisible } = useVisibility();
+  return isVisible(condition);
+}

+ 908 - 0
packages/solid/src/dynamic-forms.test.tsx

@@ -0,0 +1,908 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
+import { createSignal } from "solid-js";
+import { defineCatalog, type Spec } from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  defineRegistry,
+  type ComponentRenderProps,
+} from "./renderer";
+import type { ComponentFn } from "./catalog-types";
+import { useStateStore } from "./contexts/state";
+import { useFieldValidation } from "./contexts/validation";
+import { useBoundProp } from "./hooks";
+import { schema as solidSchema } from "./schema";
+import { z } from "zod";
+
+const exampleCatalog = defineCatalog(solidSchema, {
+  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, React, Svelte, and Solid 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",
+    },
+    switchToSvelte: {
+      params: z.object({}),
+      description: "Switch to the Svelte renderer",
+    },
+    switchToSolid: {
+      params: z.object({}),
+      description: "Switch to the Solid renderer",
+    },
+  },
+});
+
+const definedStack: ComponentFn<typeof exampleCatalog, "Stack"> = (ctx) => (
+  <div>{ctx.children}</div>
+);
+
+const definedButton: ComponentFn<typeof exampleCatalog, "Button"> = (ctx) => (
+  <button data-testid="defined-btn" onClick={() => ctx.emit("press")}>
+    {ctx.props.label}
+  </button>
+);
+
+const definedText: ComponentFn<typeof exampleCatalog, "Text"> = (ctx) => (
+  <span data-testid="defined-text">{ctx.props.content}</span>
+);
+
+const definedCard: ComponentFn<typeof exampleCatalog, "Card"> = (ctx) => (
+  <div>{ctx.children}</div>
+);
+
+const definedBadge: ComponentFn<typeof exampleCatalog, "Badge"> = (ctx) => (
+  <span>{ctx.props.label}</span>
+);
+
+const definedListItem: ComponentFn<typeof exampleCatalog, "ListItem"> = (
+  ctx,
+) => <div>{ctx.props.title}</div>;
+
+const definedRendererBadge: ComponentFn<
+  typeof exampleCatalog,
+  "RendererBadge"
+> = (ctx) => <span>{ctx.props.renderer}</span>;
+
+const definedRendererTabs: ComponentFn<
+  typeof exampleCatalog,
+  "RendererTabs"
+> = () => <div />;
+
+const exampleComponents = {
+  Stack: definedStack,
+  Button: definedButton,
+  Text: definedText,
+  Card: definedCard,
+  Badge: definedBadge,
+  ListItem: definedListItem,
+  RendererBadge: definedRendererBadge,
+  RendererTabs: definedRendererTabs,
+};
+
+function Button(props: ComponentRenderProps<{ label: string }>) {
+  return (
+    <button data-testid="btn" onClick={() => props.emit("press")}>
+      {props.element.props.label}
+    </button>
+  );
+}
+
+function Text(props: ComponentRenderProps<{ text: unknown }>) {
+  const display = () => {
+    const v = props.element.props.text;
+    if (v == null) return "";
+    return typeof v === "string" ? v : JSON.stringify(v);
+  };
+  return <span data-testid="text">{display()}</span>;
+}
+
+function InputField(
+  props: ComponentRenderProps<{
+    label?: string;
+    value?: string;
+    checks?: Array<{
+      type: string;
+      message: string;
+      args?: Record<string, unknown>;
+    }>;
+  }>,
+) {
+  const elementProps = () => props.element.props;
+  const [boundValue, setBoundValue] = useBoundProp<string>(
+    elementProps().value as string | undefined,
+    props.bindings?.value,
+  );
+  const [localValue, setLocalValue] = createSignal("");
+  const isBound = () => !!props.bindings?.value;
+  const value = () => (isBound() ? (boundValue ?? "") : localValue());
+  const setValue = (v: string) =>
+    isBound() ? setBoundValue(v) : setLocalValue(v);
+
+  const hasValidation = () =>
+    !!(props.bindings?.value && elementProps().checks?.length);
+  const config = () =>
+    hasValidation() ? { checks: elementProps().checks ?? [] } : undefined;
+  const { errors } = useFieldValidation(props.bindings?.value ?? "", config());
+
+  return (
+    <div>
+      {elementProps().label && <label>{elementProps().label}</label>}
+      <input
+        data-testid="input"
+        value={value()}
+        onInput={(e) => setValue(e.currentTarget.value)}
+      />
+      {errors().length > 0 && (
+        <span data-testid="input-error">{errors()[0]}</span>
+      )}
+    </div>
+  );
+}
+
+function SelectField(
+  props: ComponentRenderProps<{ label?: string; value?: string }>,
+) {
+  const [boundValue] = useBoundProp<string>(
+    props.element.props.value as string | undefined,
+    props.bindings?.value,
+  );
+  return <span data-testid="select-value">{boundValue ?? ""}</span>;
+}
+
+function ValidatedSelect(
+  props: ComponentRenderProps<{
+    label?: string;
+    name?: string;
+    options?: string[];
+    placeholder?: string;
+    value?: string;
+    checks?: Array<{
+      type: string;
+      message: string;
+      args?: Record<string, unknown>;
+    }>;
+    validateOn?: "change" | "blur" | "submit";
+  }>,
+) {
+  const elementProps = () => props.element.props;
+  const [boundValue, setBoundValue] = useBoundProp<string>(
+    elementProps().value as string | undefined,
+    props.bindings?.value,
+  );
+  const [localValue, setLocalValue] = createSignal("");
+  const isBound = () => !!props.bindings?.value;
+  const value = () => (isBound() ? (boundValue ?? "") : localValue());
+  const setValue = (v: string) =>
+    isBound() ? setBoundValue(v) : setLocalValue(v);
+  const validateOn = () => elementProps().validateOn ?? "change";
+
+  const hasValidation = () =>
+    !!(props.bindings?.value && elementProps().checks?.length);
+  const config = () =>
+    hasValidation()
+      ? { checks: elementProps().checks ?? [], validateOn: validateOn() }
+      : undefined;
+  const { errors, validate } = useFieldValidation(
+    props.bindings?.value ?? "",
+    config(),
+  );
+
+  const options = () => elementProps().options ?? [];
+  const name = () => elementProps().name ?? "default";
+
+  return (
+    <div>
+      {elementProps().label && <label>{elementProps().label}</label>}
+      <select
+        data-testid={`select-${name()}`}
+        value={value()}
+        onChange={(e) => {
+          setValue(e.currentTarget.value);
+          if (hasValidation() && validateOn() === "change") validate();
+          props.emit("change");
+        }}
+      >
+        <option value="">{elementProps().placeholder ?? "Select..."}</option>
+        {options().map((opt) => (
+          <option value={opt}>{opt}</option>
+        ))}
+      </select>
+      {errors().length > 0 && (
+        <span data-testid={`select-error-${name()}`}>{errors()[0]}</span>
+      )}
+    </div>
+  );
+}
+
+function Stack(props: ComponentRenderProps<Record<string, unknown>>) {
+  return <div data-testid="stack">{props.children}</div>;
+}
+
+let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
+
+function StateProbe() {
+  const ctx = useStateStore();
+  probeGetSnapshot = ctx.getSnapshot;
+  return <div data-testid="state-probe" />;
+}
+
+const registry = { Button, Text, Input: InputField, Select: SelectField };
+
+function getState(): Record<string, unknown> {
+  return probeGetSnapshot!();
+}
+
+// =============================================================================
+// $computed expressions in rendering
+// =============================================================================
+
+describe("$computed expressions in rendering", () => {
+  it("resolves a $computed prop using provided functions", () => {
+    const spec: Spec = {
+      state: { first: "Jane", last: "Doe" },
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: {
+              $computed: "fullName",
+              args: {
+                first: { $state: "/first" },
+                last: { $state: "/last" },
+              },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    const functions = {
+      fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
+    };
+
+    render(() => (
+      <JSONUIProvider
+        registry={registry}
+        initialState={spec.state}
+        functions={functions}
+      >
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>
+    ));
+
+    expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
+  });
+
+  it("renders gracefully when functions prop is omitted", () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const spec: Spec = {
+      state: {},
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: { $computed: "missing" },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>
+    ));
+
+    expect(screen.getByTestId("text").textContent).toBe("");
+    warnSpy.mockRestore();
+  });
+});
+
+// =============================================================================
+// $template expressions in rendering
+// =============================================================================
+
+describe("$template expressions in rendering", () => {
+  it("interpolates state values into a template string", () => {
+    const spec: Spec = {
+      state: { user: { name: "Alice" }, count: 3 },
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: {
+              $template: "Hello, ${/user/name}! You have ${/count} messages.",
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>
+    ));
+
+    expect(screen.getByTestId("text").textContent).toBe(
+      "Hello, Alice! You have 3 messages.",
+    );
+  });
+
+  it("resolves missing paths to empty string", () => {
+    const spec: Spec = {
+      state: {},
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: { $template: "Hi ${/name}!" },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>
+    ));
+
+    expect(screen.getByTestId("text").textContent).toBe("Hi !");
+  });
+});
+
+// =============================================================================
+// Watchers
+// =============================================================================
+
+describe("watchers (watch field)", () => {
+  it("does not fire on initial render, fires when watched state changes", async () => {
+    probeGetSnapshot = undefined;
+    const loadCities = vi.fn(async (_params: Record<string, unknown>) => {});
+
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { country: "" }, citiesLoaded: false },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Set Country" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/form/country", value: "US" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Select",
+          props: { value: { $state: "/form/country" } },
+          watch: {
+            "/form/country": {
+              action: "loadCities",
+              params: { country: { $state: "/form/country" } },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider
+        registry={reg}
+        initialState={spec.state}
+        handlers={{ loadCities }}
+      >
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    expect(loadCities).not.toHaveBeenCalled();
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      expect(loadCities).toHaveBeenCalledTimes(1);
+    });
+    expect(loadCities).toHaveBeenCalledWith(
+      expect.objectContaining({ country: "US" }),
+    );
+  });
+
+  it("fires multiple action bindings on the same watch path", async () => {
+    probeGetSnapshot = undefined;
+    const action1 = vi.fn();
+    const action2 = vi.fn();
+
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { value: "a" },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Change" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/value", value: "b" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Text",
+          props: { text: { $state: "/value" } },
+          watch: {
+            "/value": [{ action: "action1" }, { action: "action2" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider
+        registry={reg}
+        initialState={spec.state}
+        handlers={{ action1, action2 }}
+      >
+        <Renderer spec={spec} registry={reg} />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      expect(action1).toHaveBeenCalledTimes(1);
+      expect(action2).toHaveBeenCalledTimes(1);
+    });
+  });
+});
+
+describe("defineRegistry reactivity", () => {
+  it("updates $state-backed props after setState actions", async () => {
+    const { registry: definedRegistry } = defineRegistry(exampleCatalog, {
+      components: exampleComponents,
+      actions: {
+        increment: async () => {},
+        decrement: async () => {},
+        reset: async () => {},
+        toggleItem: async () => {},
+        switchToVue: async () => {},
+        switchToReact: async () => {},
+        switchToSvelte: async () => {},
+        switchToSolid: async () => {},
+      },
+    });
+
+    const spec: Spec = {
+      state: { value: "a" },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "text"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Change" },
+          on: {
+            press: {
+              action: "setState",
+              params: { statePath: "/value", value: "b" },
+            },
+          },
+          children: [],
+        },
+        text: {
+          type: "Text",
+          props: { content: { $state: "/value" } },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={definedRegistry} initialState={spec.state}>
+        <Renderer spec={spec} registry={definedRegistry} />
+      </JSONUIProvider>
+    ));
+
+    expect(screen.getByTestId("defined-text").textContent).toBe("a");
+
+    fireEvent.click(screen.getByTestId("defined-btn"));
+
+    await waitFor(() => {
+      expect(screen.getByTestId("defined-text").textContent).toBe("b");
+    });
+  });
+});
+
+// =============================================================================
+// validateForm action
+// =============================================================================
+
+describe("validateForm action", () => {
+  it("writes { valid: false } when a required field is empty", async () => {
+    probeGetSnapshot = undefined;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { email: "" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getState();
+      expect(state.result).toEqual({
+        valid: false,
+        errors: { "/form/email": ["Email is required"] },
+      });
+    });
+  });
+
+  it("writes { valid: true } when all fields pass validation", async () => {
+    probeGetSnapshot = undefined;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { email: "test@example.com" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getState();
+      expect(state.result).toEqual({ valid: true, errors: {} });
+    });
+  });
+
+  it("defaults to /formValidation when no statePath is provided", async () => {
+    probeGetSnapshot = undefined;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { name: "filled" } },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["nameInput", "submitBtn"],
+        },
+        nameInput: {
+          type: "Input",
+          props: {
+            label: "Name",
+            value: { $bindState: "/form/name" },
+            checks: [{ type: "required", message: "Required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [{ action: "validateForm" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.click(screen.getByTestId("btn"));
+
+    await waitFor(() => {
+      const state = getState();
+      expect(state.formValidation).toEqual({ valid: true, errors: {} });
+    });
+  });
+});
+
+// =============================================================================
+// Select validate-on-change timing (#151)
+// =============================================================================
+
+describe("Select validate-on-change sees the new value, not the stale value", () => {
+  const regWithSelect = {
+    ...registry,
+    Stack,
+    Select: ValidatedSelect,
+  };
+
+  it("does not show 'required' error when selecting the first value", async () => {
+    probeGetSnapshot = undefined;
+    const spec: Spec = {
+      state: { form: { country: "" } },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["countrySelect"],
+        },
+        countrySelect: {
+          type: "Select",
+          props: {
+            label: "Country",
+            name: "country",
+            options: ["US", "Canada", "UK"],
+            placeholder: "Choose a country",
+            value: { $bindState: "/form/country" },
+            checks: [{ type: "required", message: "Country is required" }],
+            validateOn: "change",
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
+        <Renderer spec={spec} registry={regWithSelect} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.change(screen.getByTestId("select-country"), {
+      target: { value: "US" },
+    });
+
+    await waitFor(() => {
+      const state = getState();
+      expect((state.form as Record<string, unknown>).country).toBe("US");
+    });
+
+    expect(screen.queryByTestId("select-error-country")).toBeNull();
+  });
+
+  it("does not show 'required' error when selecting the first city after country change resets it", async () => {
+    probeGetSnapshot = undefined;
+    const spec: Spec = {
+      state: {
+        form: { country: "US", city: "" },
+        availableCities: ["New York", "Chicago"],
+      },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["citySelect"],
+        },
+        citySelect: {
+          type: "Select",
+          props: {
+            label: "City",
+            name: "city",
+            options: ["New York", "Chicago"],
+            placeholder: "Select a city",
+            value: { $bindState: "/form/city" },
+            checks: [{ type: "required", message: "City is required" }],
+            validateOn: "change",
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(() => (
+      <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
+        <Renderer spec={spec} registry={regWithSelect} />
+        <StateProbe />
+      </JSONUIProvider>
+    ));
+
+    fireEvent.change(screen.getByTestId("select-city"), {
+      target: { value: "New York" },
+    });
+
+    await waitFor(() => {
+      const state = getState();
+      expect((state.form as Record<string, unknown>).city).toBe("New York");
+    });
+
+    expect(screen.queryByTestId("select-error-city")).toBeNull();
+  });
+});

+ 430 - 0
packages/solid/src/hooks.test.ts

@@ -0,0 +1,430 @@
+import { describe, it, expect } from "vitest";
+import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks";
+
+describe("flatToTree", () => {
+  it("converts array of elements to tree structure", () => {
+    const elements = [
+      { key: "container", type: "stack", props: {}, parentKey: null },
+      {
+        key: "text1",
+        type: "text",
+        props: { content: "Hello" },
+        parentKey: "container",
+      },
+      {
+        key: "text2",
+        type: "text",
+        props: { content: "World" },
+        parentKey: "container",
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("container");
+    expect(Object.keys(tree.elements)).toHaveLength(3);
+    expect(tree.elements["container"]).toBeDefined();
+    expect(tree.elements["text1"]).toBeDefined();
+    expect(tree.elements["text2"]).toBeDefined();
+  });
+
+  it("builds parent-child relationships", () => {
+    const elements = [
+      { key: "root", type: "stack", props: {}, parentKey: null },
+      { key: "child1", type: "text", props: {}, parentKey: "root" },
+      { key: "child2", type: "text", props: {}, parentKey: "root" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["root"]?.children).toHaveLength(2);
+    expect(tree.elements["root"]?.children).toContain("child1");
+    expect(tree.elements["root"]?.children).toContain("child2");
+  });
+
+  it("handles single root element", () => {
+    const elements = [
+      {
+        key: "only",
+        type: "text",
+        props: { content: "Single" },
+        parentKey: null,
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("only");
+    expect(Object.keys(tree.elements)).toHaveLength(1);
+  });
+
+  it("handles deeply nested elements", () => {
+    const elements = [
+      { key: "level0", type: "stack", props: {}, parentKey: null },
+      { key: "level1", type: "stack", props: {}, parentKey: "level0" },
+      { key: "level2", type: "stack", props: {}, parentKey: "level1" },
+      { key: "level3", type: "text", props: {}, parentKey: "level2" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("level0");
+    expect(tree.elements["level0"]?.children).toContain("level1");
+    expect(tree.elements["level1"]?.children).toContain("level2");
+    expect(tree.elements["level2"]?.children).toContain("level3");
+  });
+
+  it("preserves element props", () => {
+    const elements = [
+      {
+        key: "btn",
+        type: "button",
+        props: { label: "Click me", variant: "primary" },
+        parentKey: null,
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["btn"]?.props).toEqual({
+      label: "Click me",
+      variant: "primary",
+    });
+  });
+
+  it("preserves visibility conditions", () => {
+    const elements = [
+      {
+        key: "conditional",
+        type: "text",
+        props: {},
+        parentKey: null,
+        visible: { $state: "/isVisible" },
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["conditional"]?.visible).toEqual({
+      $state: "/isVisible",
+    });
+  });
+
+  it("handles elements with undefined parentKey as root", () => {
+    const elements = [
+      { key: "root", type: "stack", props: {} } as {
+        key: string;
+        type: string;
+        props: Record<string, unknown>;
+        parentKey?: string | null;
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    // Elements without parentKey should not become root (only null parentKey)
+    // This tests the edge case
+    expect(tree.elements["root"]).toBeDefined();
+  });
+
+  it("handles empty elements array", () => {
+    const tree = flatToTree([]);
+
+    expect(tree.root).toBe("");
+    expect(Object.keys(tree.elements)).toHaveLength(0);
+  });
+
+  it("handles multiple children correctly", () => {
+    const elements = [
+      { key: "parent", type: "grid", props: {}, parentKey: null },
+      { key: "a", type: "card", props: {}, parentKey: "parent" },
+      { key: "b", type: "card", props: {}, parentKey: "parent" },
+      { key: "c", type: "card", props: {}, parentKey: "parent" },
+      { key: "d", type: "card", props: {}, parentKey: "parent" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["parent"]?.children).toHaveLength(4);
+    expect(tree.elements["parent"]?.children).toEqual(["a", "b", "c", "d"]);
+  });
+});
+
+// =============================================================================
+// buildSpecFromParts
+// =============================================================================
+
+describe("buildSpecFromParts", () => {
+  it("returns null when no data-spec parts are present", () => {
+    const parts = [
+      { type: "text", text: "Hello there" },
+      { type: "text", text: "How can I help?" },
+    ];
+    expect(buildSpecFromParts(parts)).toBeNull();
+  });
+
+  it("builds a spec from patch parts", () => {
+    const parts = [
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "main" },
+        },
+      },
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: {
+            op: "add",
+            path: "/elements/main",
+            value: { type: "Card", props: { title: "Hello" }, children: [] },
+          },
+        },
+      },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    expect(spec!.root).toBe("main");
+    expect(spec!.elements.main).toEqual({
+      type: "Card",
+      props: { title: "Hello" },
+      children: [],
+    });
+  });
+
+  it("handles flat spec parts", () => {
+    const parts = [
+      {
+        type: "data-spec",
+        data: {
+          type: "flat",
+          spec: {
+            root: "card-1",
+            elements: {
+              "card-1": { type: "Card", props: {}, children: [] },
+            },
+          },
+        },
+      },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    expect(spec!.root).toBe("card-1");
+    expect(spec!.elements["card-1"]).toBeDefined();
+  });
+
+  it("ignores non-spec parts", () => {
+    const parts = [
+      { type: "text", text: "Some text" },
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "main" },
+        },
+      },
+      { type: "tool-invocation", data: { toolName: "search" } },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    expect(spec!.root).toBe("main");
+  });
+
+  it("applies patches incrementally", () => {
+    const parts = [
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "main" },
+        },
+      },
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: {
+            op: "add",
+            path: "/elements/main",
+            value: { type: "Stack", props: {}, children: ["child"] },
+          },
+        },
+      },
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: {
+            op: "add",
+            path: "/elements/child",
+            value: { type: "Text", props: { content: "Hi" }, children: [] },
+          },
+        },
+      },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    expect(Object.keys(spec!.elements)).toHaveLength(2);
+    expect(spec!.elements.child!.props.content).toBe("Hi");
+  });
+
+  it("handles nested spec parts via nestedToFlat", () => {
+    const parts = [
+      {
+        type: "data-spec",
+        data: {
+          type: "nested",
+          spec: {
+            type: "Card",
+            props: { title: "Nested" },
+            children: [
+              { type: "Text", props: { content: "Child" }, children: [] },
+            ],
+          },
+        },
+      },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    expect(spec!.root).toBeTruthy();
+    // nestedToFlat generates keys like el-0, el-1
+    const elementKeys = Object.keys(spec!.elements);
+    expect(elementKeys.length).toBe(2);
+
+    const rootEl = spec?.elements[spec.root];
+    expect(rootEl).toBeDefined();
+    expect(rootEl?.type).toBe("Card");
+    expect(rootEl?.props.title).toBe("Nested");
+    expect(rootEl?.children).toHaveLength(1);
+
+    const childKey = rootEl?.children?.[0];
+    const childEl = childKey ? spec?.elements[childKey] : undefined;
+    expect(childEl).toBeDefined();
+    expect(childEl?.type).toBe("Text");
+    expect(childEl?.props.content).toBe("Child");
+  });
+
+  it("handles mixed patch + flat + nested parts in sequence", () => {
+    const parts = [
+      // Start with a patch
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "main" },
+        },
+      },
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: {
+            op: "add",
+            path: "/elements/main",
+            value: { type: "Stack", props: {}, children: [] },
+          },
+        },
+      },
+      // Then a flat spec overwrites everything
+      {
+        type: "data-spec",
+        data: {
+          type: "flat",
+          spec: {
+            root: "card-1",
+            elements: {
+              "card-1": {
+                type: "Card",
+                props: { title: "Flat" },
+                children: [],
+              },
+            },
+          },
+        },
+      },
+    ];
+
+    const spec = buildSpecFromParts(parts);
+    expect(spec).not.toBeNull();
+    // Flat overwrites root and elements
+    expect(spec!.root).toBe("card-1");
+    expect(spec!.elements["card-1"]).toBeDefined();
+    expect(spec!.elements["card-1"]!.type).toBe("Card");
+  });
+
+  it("returns empty elements map from empty parts list", () => {
+    const spec = buildSpecFromParts([]);
+    expect(spec).toBeNull();
+  });
+});
+
+// =============================================================================
+// getTextFromParts
+// =============================================================================
+
+describe("getTextFromParts", () => {
+  it("extracts text from text parts", () => {
+    const parts = [
+      { type: "text", text: "Hello" },
+      { type: "text", text: "World" },
+    ];
+    expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
+  });
+
+  it("returns empty string when no text parts", () => {
+    const parts = [
+      {
+        type: "data-spec",
+        data: {
+          type: "patch",
+          patch: { op: "add", path: "/root", value: "x" },
+        },
+      },
+    ];
+    expect(getTextFromParts(parts)).toBe("");
+  });
+
+  it("ignores non-text parts", () => {
+    const parts = [
+      { type: "text", text: "Before" },
+      { type: "data-spec", data: {} },
+      { type: "tool-invocation", data: {} },
+      { type: "text", text: "After" },
+    ];
+    expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
+  });
+
+  it("trims whitespace from text parts", () => {
+    const parts = [
+      { type: "text", text: "  Hello  " },
+      { type: "text", text: "  World  " },
+    ];
+    expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
+  });
+
+  it("skips empty text parts", () => {
+    const parts = [
+      { type: "text", text: "Hello" },
+      { type: "text", text: "   " },
+      { type: "text", text: "World" },
+    ];
+    expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
+  });
+
+  it("ignores text parts with non-string text field", () => {
+    const parts = [
+      { type: "text", text: "Valid" },
+      { type: "text", text: undefined as unknown as string },
+      { type: "text", text: 42 as unknown as string },
+      { type: "text", text: "Also valid" },
+    ];
+    expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
+  });
+});

+ 881 - 0
packages/solid/src/hooks.ts

@@ -0,0 +1,881 @@
+import { createSignal, onCleanup, createMemo } from "solid-js";
+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 */
+  readonly spec: Spec | null;
+  /** Whether currently streaming */
+  readonly isStreaming: boolean;
+  /** Error if any */
+  readonly error: Error | null;
+  /** Token usage from the last generation */
+  readonly usage: TokenUsage | null;
+  /** Raw JSONL lines received from the stream (JSON patch lines) */
+  readonly rawLines: string[];
+  /** Send a prompt to generate UI */
+  send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
+  /** Clear the current spec */
+  clear: () => void;
+}
+
+/**
+ * Hook for streaming UI generation
+ */
+export function useUIStream(options: UseUIStreamOptions): UseUIStreamReturn {
+  const [spec, setSpec] = createSignal<Spec | null>(null);
+  const [isStreaming, setIsStreaming] = createSignal(false);
+  const [error, setError] = createSignal<Error | null>(null);
+  const [usage, setUsage] = createSignal<TokenUsage | null>(null);
+  const [rawLines, setRawLines] = createSignal<string[]>([]);
+  let abortController: AbortController | null = null;
+
+  const clear = () => {
+    setSpec(null);
+    setError(null);
+  };
+
+  const send = async (prompt: string, context?: Record<string, unknown>) => {
+    // Abort any existing request
+    abortController?.abort();
+    abortController = new AbortController();
+
+    setIsStreaming(true);
+    setError(null);
+    setUsage(null);
+    setRawLines([]);
+
+    // 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: {} };
+    setSpec(currentSpec);
+
+    try {
+      const response = await fetch(options.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") {
+            setUsage(result.usage);
+          } else {
+            setRawLines((prev) => [...prev, trimmed]);
+            currentSpec = applyPatch(currentSpec, result.patch);
+            setSpec({ ...currentSpec });
+          }
+        }
+      }
+
+      // Process any remaining buffer
+      if (buffer.trim()) {
+        const trimmed = buffer.trim();
+        const result = parseLine(trimmed);
+        if (result) {
+          if (result.type === "usage") {
+            setUsage(result.usage);
+          } else {
+            setRawLines((prev) => [...prev, trimmed]);
+            currentSpec = applyPatch(currentSpec, result.patch);
+            setSpec({ ...currentSpec });
+          }
+        }
+      }
+
+      options.onComplete?.(currentSpec);
+    } catch (err) {
+      if ((err as Error).name === "AbortError") {
+        return;
+      }
+      const resolvedError = err instanceof Error ? err : new Error(String(err));
+      setError(resolvedError);
+      options.onError?.(resolvedError);
+    } finally {
+      setIsStreaming(false);
+    }
+  };
+
+  // Cleanup on disposal
+  onCleanup(() => {
+    abortController?.abort();
+  });
+
+  return {
+    get spec() {
+      return spec();
+    },
+    get isStreaming() {
+      return isStreaming();
+    },
+    get error() {
+      return error();
+    },
+    get usage() {
+      return usage();
+    },
+    get rawLines() {
+      return 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
+// =============================================================================
+
+// Re-export useStateStore access for useBoundProp without circular import
+import { useStateStore as useStateStoreFromContext } from "./contexts/state";
+
+/**
+ * Hook 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
+ * ```tsx
+ * import { useBoundProp } from "@json-render/solid";
+ *
+ * const Input: ComponentRenderer = (ctx) => {
+ *   const [value, setValue] = useBoundProp<string>(ctx.props.value, ctx.bindings?.value);
+ *   return <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 } = useStateStoreFromContext();
+  const setValue = (value: T) => {
+    if (bindingPath) set(bindingPath, value);
+  };
+  return [propValue, setValue];
+}
+
+// =============================================================================
+// 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;
+}
+
+/**
+ * Build a `Spec` by replaying all spec data parts from a message's
+ * parts array. Returns `null` if no spec data parts are present.
+ *
+ * This function is designed to work with the AI SDK's `UIMessage.parts` array.
+ * It picks out parts whose `type` is {@link SPEC_DATA_PART_TYPE} and processes them based
+ * on the payload's `type` discriminator:
+ *
+ * - `"patch"`: Applies the JSON Patch operation incrementally via `applySpecPatch`.
+ * - `"flat"`: Replaces the spec with the complete flat spec.
+ * - `"nested"`: Assigns the nested spec directly (future: nested-to-flat conversion).
+ *
+ * The function has no AI SDK dependency -- it operates on a generic array of
+ * `{ type: string; data: unknown }` objects.
+ *
+ * @example
+ * ```tsx
+ * const spec = buildSpecFromParts(message.parts);
+ * if (spec) {
+ *   return <MyRenderer spec={spec} />;
+ * }
+ * ```
+ */
+/**
+ * Type guard that validates a data part payload looks like a valid
+ * {@link SpecDataPart} before we cast it. Returns `false` (and the
+ * part is silently skipped) for malformed payloads.
+ */
+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;
+  }
+}
+
+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.
+ *
+ * Has no AI SDK dependency -- operates on a generic `DataPart[]`.
+ *
+ * @example
+ * ```tsx
+ * const text = getTextFromParts(message.parts);
+ * if (text) {
+ *   return <Streamdown>{text}</Streamdown>;
+ * }
+ * ```
+ */
+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
+// =============================================================================
+
+/**
+ * Hook that extracts both the json-render spec and text content from a
+ * message's parts array. Combines `buildSpecFromParts` and `getTextFromParts`
+ * into a single call with memoized results.
+ *
+ * **Memoization behavior:** Results are recomputed only when the `parts`
+ * accessor returns a new array reference with a different length or last
+ * element. This is optimized for the typical AI SDK streaming pattern where
+ * parts are appended incrementally.
+ *
+ * @example
+ * ```tsx
+ * import { useJsonRenderMessage } from "@json-render/solid";
+ *
+ * function MessageBubble(props: { parts: DataPart[] }) {
+ *   const msg = useJsonRenderMessage(() => props.parts);
+ *
+ *   return (
+ *     <div>
+ *       {msg.text && <Markdown>{msg.text}</Markdown>}
+ *       {msg.hasSpec && <MyRenderer spec={msg.spec!} />}
+ *     </div>
+ *   );
+ * }
+ * ```
+ */
+export function useJsonRenderMessage(getParts: () => DataPart[]) {
+  const result = createMemo(() => {
+    const parts = getParts();
+    return {
+      spec: buildSpecFromParts(parts),
+      text: getTextFromParts(parts),
+    };
+  });
+
+  return {
+    get spec() {
+      return result().spec;
+    },
+    get text() {
+      return result().text;
+    },
+    get hasSpec() {
+      const s = result().spec;
+      return s !== null && Object.keys(s.elements || {}).length > 0;
+    },
+  };
+}
+
+// =============================================================================
+// useChatUI — Chat + GenUI hook
+// =============================================================================
+
+/**
+ * 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 */
+  readonly messages: ChatMessage[];
+  /** Whether currently streaming an assistant response */
+  readonly isStreaming: boolean;
+  /** Error from the last request, if any */
+  readonly error: 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}`;
+}
+
+/**
+ * Hook for chat + GenUI experiences.
+ *
+ * Manages a multi-turn conversation where each assistant message can contain
+ * both conversational text and a json-render UI spec. The hook sends the full
+ * message history to the API endpoint, reads the streamed response, and
+ * separates text lines from JSONL patch lines using `createMixedStreamParser`.
+ *
+ * @example
+ * ```tsx
+ * const chat = useChatUI({ api: "/api/chat" });
+ *
+ * // Send a message
+ * await chat.send("Compare weather in NYC and Tokyo");
+ *
+ * // Render messages
+ * <For each={chat.messages}>
+ *   {(msg) => (
+ *     <div>
+ *       {msg.text && <p>{msg.text}</p>}
+ *       {msg.spec && <MyRenderer spec={msg.spec} />}
+ *     </div>
+ *   )}
+ * </For>
+ * ```
+ */
+export function useChatUI(options: UseChatUIOptions): UseChatUIReturn {
+  const [messages, setMessages] = createSignal<ChatMessage[]>([]);
+  const [isStreaming, setIsStreaming] = createSignal(false);
+  const [error, setError] = createSignal<Error | null>(null);
+  let abortController: AbortController | null = null;
+
+  const clear = () => {
+    setMessages([]);
+    setError(null);
+  };
+
+  const send = async (text: string) => {
+    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
+    setMessages((prev) => [...prev, userMessage, assistantMessage]);
+    setIsStreaming(true);
+    setError(null);
+
+    // Build messages array for the API (full conversation history + new message).
+    // Read current messages signal for latest state (avoids stale closure).
+    const historyForApi = [
+      ...messages()
+        .filter((m) => m.id !== userMessage.id && 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(options.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);
+          setMessages((prev) =>
+            prev.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;
+          setMessages((prev) =>
+            prev.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,
+      };
+      options.onComplete?.(finalMessage);
+    } catch (err) {
+      if ((err as Error).name === "AbortError") {
+        return;
+      }
+      const resolvedError = err instanceof Error ? err : new Error(String(err));
+      setError(resolvedError);
+      // Remove empty assistant message on error
+      setMessages((prev) =>
+        prev.filter((m) => m.id !== assistantId || m.text.length > 0),
+      );
+      options.onError?.(resolvedError);
+    } finally {
+      setIsStreaming(false);
+    }
+  };
+
+  // Cleanup on disposal
+  onCleanup(() => {
+    abortController?.abort();
+  });
+
+  return {
+    get messages() {
+      return messages();
+    },
+    get isStreaming() {
+      return isStreaming();
+    },
+    get error() {
+      return error();
+    },
+    send,
+    clear,
+  };
+}

+ 109 - 0
packages/solid/src/index.ts

@@ -0,0 +1,109 @@
+// Contexts
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "./contexts/state";
+
+export {
+  VisibilityProvider,
+  useVisibility,
+  useIsVisible,
+  type VisibilityContextValue,
+  type VisibilityProviderProps,
+} from "./contexts/visibility";
+
+export {
+  ActionProvider,
+  useActions,
+  useAction,
+  ConfirmDialog,
+  type ActionContextValue,
+  type ActionProviderProps,
+  type PendingConfirmation,
+  type ConfirmDialogProps,
+} from "./contexts/actions";
+
+export {
+  ValidationProvider,
+  useValidation,
+  useOptionalValidation,
+  useFieldValidation,
+  type ValidationContextValue,
+  type ValidationProviderProps,
+  type FieldValidationState,
+} from "./contexts/validation";
+
+export {
+  RepeatScopeProvider,
+  useRepeatScope,
+  type RepeatScopeValue,
+} from "./contexts/repeat-scope";
+
+// Schema (Solid's spec format)
+export {
+  schema,
+  type SolidSchema,
+  type SolidSpec,
+  // Backward compatibility
+  elementTreeSchema,
+  type ElementTreeSchema,
+  type ElementTreeSpec,
+} 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 Solid
+export type {
+  EventHandle,
+  BaseComponentProps,
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+  ActionFn,
+  Actions,
+} from "./catalog-types";
+
+// Renderer
+export {
+  // Registry
+  defineRegistry,
+  type DefineRegistryResult,
+  // createRenderer (higher-level, includes providers)
+  createRenderer,
+  type CreateRendererProps,
+  type ComponentMap,
+  // Low-level
+  Renderer,
+  JSONUIProvider,
+  type ComponentRenderProps,
+  type ComponentRenderer,
+  type ComponentRegistry,
+  type RendererProps,
+  type JSONUIProviderProps,
+} from "./renderer";
+
+// 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";

+ 50 - 0
packages/solid/src/renderer.test.tsx

@@ -0,0 +1,50 @@
+import { describe, it, expect } from "vitest";
+import { Renderer } from "./renderer";
+
+describe("Renderer", () => {
+  it("is a valid component function", () => {
+    expect(typeof Renderer).toBe("function");
+  });
+
+  it("accepts null spec", () => {
+    const props = {
+      spec: null,
+      registry: {},
+    };
+    expect(props.spec).toBeNull();
+    expect(props.registry).toEqual({});
+  });
+
+  it("accepts spec without root", () => {
+    const props = {
+      spec: { root: "", elements: {} },
+      registry: {},
+    };
+    expect(props.spec.root).toBe("");
+    expect(props.spec.elements).toEqual({});
+  });
+
+  it("accepts loading prop", () => {
+    const props = {
+      spec: null,
+      registry: {},
+      loading: true,
+    };
+    expect(props.loading).toBe(true);
+  });
+
+  it("accepts fallback prop", () => {
+    const Fallback = () => {
+      const el = document.createElement("div");
+      el.textContent = "Unknown component";
+      return el;
+    };
+
+    const props = {
+      spec: null,
+      registry: {},
+      fallback: Fallback,
+    };
+    expect(props.fallback).toBe(Fallback);
+  });
+});

+ 811 - 0
packages/solid/src/renderer.tsx

@@ -0,0 +1,811 @@
+import {
+  createContext,
+  useContext,
+  createMemo,
+  createEffect,
+  onCleanup,
+  ErrorBoundary,
+  Show,
+  For,
+  type JSX,
+  type Component,
+} from "solid-js";
+import type {
+  UIElement,
+  Spec,
+  ActionBinding,
+  Catalog,
+  SchemaDefinition,
+  StateStore,
+  ComputedFunction,
+} from "@json-render/core";
+import {
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import type {
+  Components,
+  Actions,
+  ActionFn,
+  SetState,
+  StateModel,
+  CatalogHasActions,
+  EventHandle,
+} from "./catalog-types";
+import { useIsVisible, useVisibility } from "./contexts/visibility";
+import { useActions } from "./contexts/actions";
+import { useStateStore } from "./contexts/state";
+import { StateProvider } from "./contexts/state";
+import { VisibilityProvider } from "./contexts/visibility";
+import { ActionProvider } from "./contexts/actions";
+import { ValidationProvider } from "./contexts/validation";
+import { ConfirmDialog } from "./contexts/actions";
+import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
+
+/**
+ * Props passed to component renderers
+ */
+export interface ComponentRenderProps<P = Record<string, unknown>> {
+  /** The element being rendered */
+  element: UIElement<string, P>;
+  /** Rendered children */
+  children?: JSX.Element;
+  /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
+  emit: (event: string) => void;
+  /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
+  on: (event: string) => EventHandle;
+  /**
+   * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
+   * Maps prop name → absolute state path for write-back.
+   * Only present when at least one prop uses `{ $bindState: "..." }` or `{ $bindItem: "..." }`.
+   */
+  bindings?: Record<string, string>;
+  /** Whether the parent is loading */
+  loading?: boolean;
+}
+
+/**
+ * Component renderer type
+ */
+export type ComponentRenderer<P = Record<string, unknown>> = Component<
+  ComponentRenderProps<P>
+>;
+
+/**
+ * Registry of component renderers
+ */
+export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
+
+/**
+ * Props for the Renderer component
+ */
+export interface RendererProps {
+  /** The UI spec to render */
+  spec: Spec | null;
+  /** Component registry */
+  registry: ComponentRegistry;
+  /** Whether the spec is currently loading/streaming */
+  loading?: boolean;
+  /** Fallback component for unknown types */
+  fallback?: ComponentRenderer;
+}
+
+// ---------------------------------------------------------------------------
+// FunctionsContext – provides $computed functions to the element tree
+// ---------------------------------------------------------------------------
+
+const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
+
+const FunctionsContext =
+  createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
+
+function useFunctions(): Record<string, ComputedFunction> {
+  return useContext(FunctionsContext) ?? EMPTY_FUNCTIONS;
+}
+
+interface ElementRendererProps {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+/**
+ * Element renderer component.
+ */
+function ElementRenderer(props: ElementRendererProps) {
+  const repeatScope = useRepeatScope();
+  const { ctx } = useVisibility();
+  const { execute } = useActions();
+  const stateStore = useStateStore();
+  const getSnapshot = () => stateStore.getSnapshot();
+  const functions = useFunctions();
+
+  // Build context with repeat scope and $computed functions
+  const fullCtx = createMemo<PropResolutionContext>(() => {
+    const repeatItem = repeatScope?.item;
+    const repeatIndex = repeatScope?.index;
+    const repeatBasePath = repeatScope?.basePath;
+
+    return {
+      get stateModel() {
+        return ctx.stateModel;
+      },
+      ...(repeatItem !== undefined ? { repeatItem } : {}),
+      ...(repeatIndex !== undefined ? { repeatIndex } : {}),
+      ...(repeatBasePath !== undefined ? { repeatBasePath } : {}),
+      functions,
+    };
+  });
+
+  // Evaluate visibility (now supports $item/$index inside repeat scopes)
+  const isVisible = createMemo(() =>
+    props.element.visible === undefined
+      ? true
+      : evaluateVisibility(props.element.visible, fullCtx()),
+  );
+
+  // Create emit function that resolves events to action bindings.
+  const emit = async (eventName: string) => {
+    const onBindings = props.element.on;
+    const binding = onBindings?.[eventName];
+    if (!binding) return;
+    const actionBindings = Array.isArray(binding) ? binding : [binding];
+    for (const b of actionBindings) {
+      if (!b.params) {
+        await execute(b);
+        continue;
+      }
+      // Build a fresh context with live store state so that $state
+      // references in later actions see mutations from earlier ones.
+      const liveCtx: PropResolutionContext = {
+        ...fullCtx(),
+        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 that returns an EventHandle with metadata for a specific event.
+  const on = (eventName: string): EventHandle => {
+    const onBindings = props.element.on;
+    const binding = onBindings?.[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: () => emit(eventName),
+      shouldPreventDefault,
+      bound: true,
+    };
+  };
+
+  // Watch effect: fire actions when watched state paths change.
+  createEffect(() => {
+    const watchConfig = props.element.watch;
+    if (!watchConfig) return;
+    const paths = Object.keys(watchConfig);
+    if (paths.length === 0) return;
+
+    const unsubscribe = stateStore.subscribeChanges((changes) => {
+      const changedPaths = new Set(changes.map((change) => change.path));
+
+      void (async () => {
+        for (const path of paths) {
+          if (!changedPaths.has(path)) continue;
+
+          const binding = watchConfig[path];
+          if (!binding) continue;
+          const bindings = Array.isArray(binding) ? binding : [binding];
+
+          for (const b of bindings) {
+            if (!b.params) {
+              await execute(b);
+              continue;
+            }
+            const liveCtx: PropResolutionContext = {
+              ...fullCtx(),
+              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 });
+          }
+        }
+      })().catch(console.error);
+    });
+
+    onCleanup(() => {
+      unsubscribe();
+    });
+  });
+
+  // Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
+  const elementBindings = createMemo(() => {
+    const rawProps = props.element.props as Record<string, unknown>;
+    return resolveBindings(rawProps, fullCtx());
+  });
+
+  // Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
+  const resolvedElement = createMemo(() => {
+    const rawProps = props.element.props as Record<string, unknown>;
+    const resolvedProps = resolveElementProps(rawProps, fullCtx());
+    return resolvedProps !== props.element.props
+      ? { ...props.element, props: resolvedProps }
+      : props.element;
+  });
+
+  return (
+    <Show when={isVisible()}>
+      <ErrorBoundary
+        fallback={(err) => {
+          console.error(
+            `[json-render] Rendering error in <${props.element.type}>:`,
+            err,
+          );
+          return null;
+        }}
+      >
+        <ElementRendererContent
+          resolvedElement={resolvedElement()}
+          spec={props.spec}
+          registry={props.registry}
+          loading={props.loading}
+          fallback={props.fallback}
+          emit={emit}
+          on={on}
+          bindings={elementBindings()}
+        />
+      </ErrorBoundary>
+    </Show>
+  );
+}
+
+interface ElementRendererContentProps {
+  resolvedElement: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+  emit: (event: string) => void;
+  on: (event: string) => EventHandle;
+  bindings?: Record<string, string>;
+}
+
+/**
+ * Inner content renderer, separated so it can be wrapped in Show + ErrorBoundary.
+ */
+function ElementRendererContent(props: ElementRendererContentProps) {
+  // Get the component renderer
+  const Comp = () =>
+    props.registry[props.resolvedElement.type] ?? props.fallback;
+
+  return (
+    <Show
+      when={Comp()}
+      fallback={(() => {
+        console.warn(
+          `No renderer for component type: ${props.resolvedElement.type}`,
+        );
+        return null;
+      })()}
+    >
+      {(ComponentFn) => {
+        const Component = ComponentFn();
+        // Render children (with repeat support)
+        const children = () =>
+          props.resolvedElement.repeat ? (
+            <RepeatChildren
+              element={props.resolvedElement}
+              spec={props.spec}
+              registry={props.registry}
+              loading={props.loading}
+              fallback={props.fallback}
+            />
+          ) : (
+            <For each={props.resolvedElement.children ?? []}>
+              {(childKey) => {
+                const childElement = () => props.spec.elements[childKey];
+                return (
+                  <Show
+                    when={childElement()}
+                    fallback={(() => {
+                      if (!props.loading) {
+                        console.warn(
+                          `[json-render] Missing element "${childKey}" referenced as child of "${props.resolvedElement.type}". This element will not render.`,
+                        );
+                      }
+                      return null;
+                    })()}
+                  >
+                    {(el) => (
+                      <ElementRenderer
+                        element={el()}
+                        spec={props.spec}
+                        registry={props.registry}
+                        loading={props.loading}
+                        fallback={props.fallback}
+                      />
+                    )}
+                  </Show>
+                );
+              }}
+            </For>
+          );
+
+        return (
+          <Component
+            element={props.resolvedElement}
+            emit={props.emit}
+            on={props.on}
+            bindings={props.bindings}
+            loading={props.loading}
+          >
+            {children()}
+          </Component>
+        );
+      }}
+    </Show>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// RepeatChildren -- renders child elements once per item in a state array.
+// Used when an element has a `repeat` field.
+// ---------------------------------------------------------------------------
+
+interface RepeatChildrenProps {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+function RepeatChildren(props: RepeatChildrenProps) {
+  const stateStore = useStateStore();
+  const repeat = () => props.element.repeat!;
+  const statePath = () => repeat().statePath;
+
+  const items = () =>
+    (getByPath(stateStore.state, statePath()) as unknown[] | undefined) ?? [];
+
+  return (
+    <For each={items()}>
+      {(itemValue, index) => {
+        const key = () => {
+          const r = repeat();
+          return r.key && typeof itemValue === "object" && itemValue !== null
+            ? String((itemValue as Record<string, unknown>)[r.key] ?? index())
+            : String(index());
+        };
+
+        return (
+          <RepeatScopeProvider
+            item={itemValue}
+            index={index()}
+            basePath={`${statePath()}/${index()}`}
+          >
+            <For each={props.element.children ?? []}>
+              {(childKey) => {
+                const childElement = () => props.spec.elements[childKey];
+                return (
+                  <Show
+                    when={childElement()}
+                    fallback={(() => {
+                      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;
+                    })()}
+                  >
+                    {(el) => (
+                      <ElementRenderer
+                        element={el()}
+                        spec={props.spec}
+                        registry={props.registry}
+                        loading={props.loading}
+                        fallback={props.fallback}
+                      />
+                    )}
+                  </Show>
+                );
+              }}
+            </For>
+          </RepeatScopeProvider>
+        );
+      }}
+    </For>
+  );
+}
+
+/**
+ * Main renderer component
+ */
+export function Renderer(props: RendererProps) {
+  const rootElement = createMemo(() => {
+    const spec = props.spec;
+    if (!spec || !spec.root) return undefined;
+    return spec.elements[spec.root];
+  });
+
+  return (
+    <Show when={rootElement()}>
+      {(el) => (
+        <ElementRenderer
+          element={el()}
+          spec={props.spec!}
+          registry={props.registry}
+          loading={props.loading}
+          fallback={props.fallback}
+        />
+      )}
+    </Show>
+  );
+}
+
+/**
+ * Props for JSONUIProvider
+ */
+export interface JSONUIProviderProps {
+  /** Component registry */
+  registry: ComponentRegistry;
+  /**
+   * External store (controlled mode). When provided, `initialState` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** Initial state model (uncontrolled mode) */
+  initialState?: Record<string, unknown>;
+  /** Action handlers */
+  handlers?: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<unknown> | unknown
+  >;
+  /** Navigation function */
+  navigate?: (path: string) => void;
+  /** Custom validation functions */
+  validationFunctions?: Record<
+    string,
+    (value: unknown, args?: Record<string, unknown>) => boolean
+  >;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  children: JSX.Element;
+}
+
+/**
+ * Combined provider for all JSONUI contexts
+ */
+export function JSONUIProvider(props: JSONUIProviderProps) {
+  return (
+    <StateProvider
+      store={props.store}
+      initialState={props.initialState}
+      onStateChange={props.onStateChange}
+    >
+      <VisibilityProvider>
+        <ValidationProvider customFunctions={props.validationFunctions}>
+          <ActionProvider handlers={props.handlers} navigate={props.navigate}>
+            <FunctionsContext.Provider
+              value={props.functions ?? EMPTY_FUNCTIONS}
+            >
+              {props.children}
+              <ConfirmationDialogManager />
+            </FunctionsContext.Provider>
+          </ActionProvider>
+        </ValidationProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+
+/**
+ * Renders the confirmation dialog when needed
+ */
+function ConfirmationDialogManager() {
+  const { pendingConfirmation, confirm, cancel } = useActions();
+
+  return (
+    <Show when={pendingConfirmation?.action.confirm}>
+      {(confirmConfig) => (
+        <ConfirmDialog
+          confirm={confirmConfig()}
+          onConfirm={confirm}
+          onCancel={cancel}
+        />
+      )}
+    </Show>
+  );
+}
+
+// ============================================================================
+// defineRegistry
+// ============================================================================
+
+/**
+ * Result returned by defineRegistry
+ */
+export interface DefineRegistryResult {
+  /** Component registry for `<Renderer registry={...} />` */
+  registry: ComponentRegistry;
+  /**
+   * Create ActionProvider-compatible handlers.
+   * Accepts getter functions so handlers always read the latest state/setState
+   * (e.g. from refs or closures).
+   */
+  handlers: (
+    getSetState: () => SetState | undefined,
+    getState: () => StateModel,
+  ) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
+  /**
+   * Execute an action by name imperatively
+   * (for use outside the Solid tree, e.g. initial state loading).
+   */
+  executeAction: (
+    actionName: string,
+    params: Record<string, unknown> | undefined,
+    setState: SetState,
+    state?: StateModel,
+  ) => Promise<void>;
+}
+
+/**
+ * Options for defineRegistry.
+ *
+ * When the catalog declares actions, the `actions` field is required.
+ * When the catalog has no actions (or `actions: {}`), the field is optional.
+ */
+type DefineRegistryOptions<C extends Catalog> = {
+  components?: Components<C>;
+} & (CatalogHasActions<C> extends true
+  ? { actions: Actions<C> }
+  : { actions?: Actions<C> });
+
+/**
+ * Create a registry from a catalog with components and/or actions.
+ *
+ * When the catalog declares actions, the `actions` field is required.
+ *
+ * @example
+ * ```tsx
+ * // Components only (catalog has no actions)
+ * const { registry } = defineRegistry(catalog, {
+ *   components: {
+ *     Card: (ctx) => (
+ *       <div class="card">{ctx.props.title}{ctx.children}</div>
+ *     ),
+ *   },
+ * });
+ *
+ * // Both (catalog declares actions)
+ * const { registry, handlers, executeAction } = defineRegistry(catalog, {
+ *   components: { ... },
+ *   actions: { ... },
+ * });
+ * ```
+ */
+export function defineRegistry<C extends Catalog>(
+  _catalog: C,
+  options: DefineRegistryOptions<C>,
+): DefineRegistryResult {
+  // Build component registry
+  const registry: ComponentRegistry = {};
+  if (options.components) {
+    for (const [name, componentFn] of Object.entries(options.components)) {
+      registry[name] = (renderProps: ComponentRenderProps) => {
+        return (componentFn as DefineRegistryComponentFn)({
+          get props() {
+            return renderProps.element.props;
+          },
+          get children() {
+            return renderProps.children;
+          },
+          emit: renderProps.emit,
+          on: renderProps.on,
+          get bindings() {
+            return renderProps.bindings;
+          },
+          get loading() {
+            return renderProps.loading;
+          },
+        });
+      };
+    }
+  }
+
+  // Build action helpers
+  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 };
+}
+
+/** @internal */
+type DefineRegistryComponentFn = (ctx: {
+  props: unknown;
+  children?: JSX.Element;
+  emit: (event: string) => void;
+  on: (event: string) => EventHandle;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}) => JSX.Element;
+
+/** @internal */
+type DefineRegistryActionFn = (
+  params: Record<string, unknown> | undefined,
+  setState: SetState,
+  state: StateModel,
+) => Promise<void>;
+
+// ============================================================================
+// NEW API
+// ============================================================================
+
+/**
+ * Props for renderers created with createRenderer
+ */
+export interface CreateRendererProps {
+  /** The spec to render (AI-generated JSON) */
+  spec: Spec | null;
+  /**
+   * External store (controlled mode). When provided, `state` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** State context for dynamic values (uncontrolled mode) */
+  state?: Record<string, unknown>;
+  /** Action handler */
+  onAction?: (actionName: string, params?: Record<string, unknown>) => void;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
+  /** Whether the spec is currently loading/streaming */
+  loading?: boolean;
+  /** Fallback component for unknown types */
+  fallback?: ComponentRenderer;
+}
+
+/**
+ * Component map type - maps component names to Solid components
+ */
+export type ComponentMap<
+  TComponents extends Record<string, { props: unknown }>,
+> = {
+  [K in keyof TComponents]: Component<
+    ComponentRenderProps<
+      TComponents[K]["props"] extends { _output: infer O }
+        ? O
+        : Record<string, unknown>
+    >
+  >;
+};
+
+/**
+ * Create a renderer from a catalog
+ *
+ * @example
+ * ```typescript
+ * const DashboardRenderer = createRenderer(dashboardCatalog, {
+ *   Card: (renderProps) => <div class="card">{renderProps.children}</div>,
+ *   Metric: (renderProps) => <span>{renderProps.element.props.value}</span>,
+ * });
+ *
+ * // Usage
+ * <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<CreateRendererProps> {
+  // Convert component map to registry
+  const registry: ComponentRegistry =
+    components as unknown as ComponentRegistry;
+
+  // Return the renderer component
+  return function CatalogRenderer(props: CreateRendererProps) {
+    // Wrap onAction with a Proxy so any action name routes to the callback
+    const actionHandlers = () =>
+      props.onAction
+        ? new Proxy(
+            {} as Record<
+              string,
+              (params: Record<string, unknown>) => void | Promise<void>
+            >,
+            {
+              get: (_target, prop: string) => {
+                return (params: Record<string, unknown>) =>
+                  props.onAction!(prop, params);
+              },
+              has: () => true,
+            },
+          )
+        : undefined;
+
+    return (
+      <StateProvider
+        store={props.store}
+        initialState={props.state}
+        onStateChange={props.onStateChange}
+      >
+        <VisibilityProvider>
+          <ValidationProvider>
+            <ActionProvider handlers={actionHandlers()}>
+              <FunctionsContext.Provider
+                value={props.functions ?? EMPTY_FUNCTIONS}
+              >
+                <Renderer
+                  spec={props.spec}
+                  registry={registry}
+                  loading={props.loading}
+                  fallback={props.fallback}
+                />
+                <ConfirmationDialogManager />
+              </FunctionsContext.Provider>
+            </ActionProvider>
+          </ValidationProvider>
+        </VisibilityProvider>
+      </StateProvider>
+    );
+  };
+}

+ 117 - 0
packages/solid/src/schema.ts

@@ -0,0 +1,117 @@
+import { defineSchema } from "@json-render/core";
+
+/**
+ * The schema for @json-render/solid
+ *
+ * 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 }",
+      },
+      {
+        name: "validateForm",
+        description:
+          "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
+      },
+    ],
+    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 Solid schema
+ */
+export type SolidSchema = typeof schema;
+
+/**
+ * Infer the spec type from a catalog
+ */
+export type SolidSpec<TCatalog> = typeof schema extends {
+  createCatalog: (catalog: TCatalog) => { _specType: infer S };
+}
+  ? S
+  : never;
+
+// Backward compatibility aliases
+/** @deprecated Use `schema` instead */
+export const elementTreeSchema = schema;
+/** @deprecated Use `SolidSchema` instead */
+export type ElementTreeSchema = SolidSchema;
+/** @deprecated Use `SolidSpec` instead */
+export type ElementTreeSpec<T> = SolidSpec<T>;

+ 11 - 0
packages/solid/tsconfig.json

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

+ 12 - 0
packages/solid/tsup.config.ts

@@ -0,0 +1,12 @@
+import { defineConfig } from "tsup";
+import { solidPlugin } from "esbuild-plugin-solid";
+
+export default defineConfig({
+  entry: ["src/index.ts", "src/schema.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  esbuildPlugins: [solidPlugin({ solid: { generate: "dom" } })],
+  external: ["solid-js", "@json-render/core"],
+});

+ 231 - 25
pnpm-lock.yaml

@@ -11,6 +11,9 @@ importers:
       '@changesets/cli':
         specifier: 2.29.8
         version: 2.29.8(@types/node@22.19.6)
+      '@solidjs/testing-library':
+        specifier: ^0.8.10
+        version: 0.8.10(solid-js@1.9.11)
       '@sveltejs/vite-plugin-svelte':
         specifier: ^6.2.4
         version: 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -44,6 +47,9 @@ importers:
       react-dom:
         specifier: ^19.2.4
         version: 19.2.4(react@19.2.4)
+      solid-js:
+        specifier: ^1.9.11
+        version: 1.9.11
       svelte:
         specifier: ^5.0.0
         version: 5.53.5
@@ -53,6 +59,9 @@ importers:
       typescript:
         specifier: 5.9.2
         version: 5.9.2
+      vite-plugin-solid:
+        specifier: ^2.11.10
+        version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
       vitest:
         specifier: ^4.0.17
         version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -103,10 +112,10 @@ importers:
         version: 1.36.1
       '@vercel/analytics':
         specifier: ^1.6.1
-        version: 1.6.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))
+        version: 1.6.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))
       '@vercel/speed-insights':
         specifier: ^1.3.1
-        version: 1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))
+        version: 1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))
       '@visual-json/react':
         specifier: 0.1.1
         version: 0.1.1(react@19.2.3)
@@ -127,7 +136,7 @@ importers:
         version: 8.6.0(react@19.2.3)
       geist:
         specifier: 1.7.0
-        version: 1.7.0(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
+        version: 1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
       just-bash:
         specifier: 2.9.6
         version: 2.9.6
@@ -136,7 +145,7 @@ importers:
         version: 0.562.0(react@19.2.3)
       next:
         specifier: 16.1.1
-        version: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        version: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -360,7 +369,7 @@ importers:
         version: 0.562.0(react@19.2.3)
       next:
         specifier: 16.1.1
-        version: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        version: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -889,7 +898,7 @@ importers:
         version: 2.1.1
       next:
         specifier: 16.1.1
-        version: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+        version: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1127,6 +1136,9 @@ importers:
       '@json-render/react':
         specifier: workspace:*
         version: link:../../packages/react
+      '@json-render/solid':
+        specifier: workspace:*
+        version: link:../../packages/solid
       '@json-render/svelte':
         specifier: workspace:*
         version: link:../../packages/svelte
@@ -1139,6 +1151,9 @@ importers:
       react-dom:
         specifier: ^19.2.4
         version: 19.2.4(react@19.2.4)
+      solid-js:
+        specifier: ^1.9.11
+        version: 1.9.11
       svelte:
         specifier: ^5.49.2
         version: 5.53.5
@@ -1170,6 +1185,9 @@ importers:
       vite:
         specifier: ^7.3.1
         version: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+      vite-plugin-solid:
+        specifier: ^2.11.10
+        version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
 
   examples/vue:
     dependencies:
@@ -1599,6 +1617,31 @@ importers:
         specifier: ^4.3.6
         version: 4.3.6
 
+  packages/solid:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      esbuild-plugin-solid:
+        specifier: ^0.6.0
+        version: 0.6.0(esbuild@0.27.2)(solid-js@1.9.11)
+      solid-js:
+        specifier: ^1.9.0
+        version: 1.9.11
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+      zod:
+        specifier: ^4.3.6
+        version: 4.3.6
+
   packages/svelte:
     dependencies:
       '@json-render/core':
@@ -1696,7 +1739,7 @@ importers:
         version: link:../typescript-config
       '@xstate/store':
         specifier: ^3.0.0
-        version: 3.15.0(react@19.2.4)
+        version: 3.15.0(react@19.2.4)(solid-js@1.9.11)
       tsup:
         specifier: ^8.0.2
         version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
@@ -1910,6 +1953,10 @@ packages:
     resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
     engines: {node: '>=6.9.0'}
 
+  '@babel/helper-module-imports@7.18.6':
+    resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+    engines: {node: '>=6.9.0'}
+
   '@babel/helper-module-imports@7.28.6':
     resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
     engines: {node: '>=6.9.0'}
@@ -5472,6 +5519,16 @@ packages:
   '@sinonjs/fake-timers@8.1.0':
     resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==}
 
+  '@solidjs/testing-library@0.8.10':
+    resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==}
+    engines: {node: '>= 14'}
+    peerDependencies:
+      '@solidjs/router': '>=0.9.0'
+      solid-js: '>=1.0.0'
+    peerDependenciesMeta:
+      '@solidjs/router':
+        optional: true
+
   '@standard-schema/spec@1.1.0':
     resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
 
@@ -6627,6 +6684,11 @@ packages:
     resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
 
+  babel-plugin-jsx-dom-expressions@0.40.5:
+    resolution: {integrity: sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg==}
+    peerDependencies:
+      '@babel/core': ^7.20.12
+
   babel-plugin-polyfill-corejs2@0.4.15:
     resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==}
     peerDependencies:
@@ -6686,6 +6748,15 @@ packages:
     peerDependencies:
       '@babel/core': ^7.0.0
 
+  babel-preset-solid@1.9.10:
+    resolution: {integrity: sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+      solid-js: ^1.9.10
+    peerDependenciesMeta:
+      solid-js:
+        optional: true
+
   bail@2.0.2:
     resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
 
@@ -7711,6 +7782,12 @@ packages:
   esast-util-from-js@2.0.1:
     resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
 
+  esbuild-plugin-solid@0.6.0:
+    resolution: {integrity: sha512-V1FvDALwLDX6K0XNYM9CMRAnMzA0+Ecu55qBUT9q/eAJh1KIDsTMFoOzMSgyHqbOfvrVfO3Mws3z7TW2GVnIZA==}
+    peerDependencies:
+      esbuild: '>=0.20'
+      solid-js: '>= 1.0'
+
   esbuild-register@3.6.0:
     resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
     peerDependencies:
@@ -8546,6 +8623,9 @@ packages:
     resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
     engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
 
+  html-entities@2.3.3:
+    resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
+
   html-escaper@2.0.2:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
 
@@ -8901,6 +8981,10 @@ packages:
     resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
     engines: {node: '>= 0.4'}
 
+  is-what@4.1.16:
+    resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+    engines: {node: '>=12.13'}
+
   is-windows@1.0.2:
     resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
     engines: {node: '>=0.10.0'}
@@ -9705,6 +9789,10 @@ packages:
   memoize-one@5.2.1:
     resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
 
+  merge-anything@5.1.7:
+    resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
+    engines: {node: '>=12.13'}
+
   merge-descriptors@2.0.0:
     resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
     engines: {node: '>=18'}
@@ -11199,6 +11287,16 @@ packages:
   serialize-javascript@6.0.2:
     resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
 
+  seroval-plugins@1.5.1:
+    resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      seroval: ^1.0
+
+  seroval@1.5.1:
+    resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
+    engines: {node: '>=10'}
+
   serve-static@1.16.3:
     resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
     engines: {node: '>= 0.8.0'}
@@ -11319,6 +11417,14 @@ packages:
     resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
     engines: {node: '>= 18'}
 
+  solid-js@1.9.11:
+    resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
+
+  solid-refresh@0.6.3:
+    resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
+    peerDependencies:
+      solid-js: ^1.3
+
   sonner@2.0.7:
     resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
     peerDependencies:
@@ -12225,6 +12331,16 @@ packages:
       rollup: ^4.44.1
       vite: ^5.4.11 || ^6.0.0 || ^7.0.0
 
+  vite-plugin-solid@2.11.10:
+    resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==}
+    peerDependencies:
+      '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.*
+      solid-js: ^1.7.2
+      vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+    peerDependenciesMeta:
+      '@testing-library/jest-dom':
+        optional: true
+
   vite@6.4.1:
     resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -12921,6 +13037,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@babel/helper-module-imports@7.18.6':
+    dependencies:
+      '@babel/types': 7.29.0
+
   '@babel/helper-module-imports@7.28.6':
     dependencies:
       '@babel/traverse': 7.29.0
@@ -14234,6 +14354,7 @@ snapshots:
       - graphql
       - supports-color
       - utf-8-validate
+    optional: true
 
   '@expo/code-signing-certificates@0.0.6':
     dependencies:
@@ -14298,6 +14419,7 @@ snapshots:
     optionalDependencies:
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
   '@expo/env@2.0.8':
     dependencies:
@@ -14367,7 +14489,7 @@ snapshots:
       postcss: 8.4.49
       resolve-from: 5.0.0
     optionalDependencies:
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
     transitivePeerDependencies:
       - bufferutil
       - supports-color
@@ -14448,7 +14570,7 @@ snapshots:
       '@expo/json-file': 10.0.8
       '@react-native/normalize-colors': 0.81.5
       debug: 4.4.3
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
       resolve-from: 5.0.0
       semver: 7.7.3
       xml2js: 0.6.0
@@ -14476,6 +14598,7 @@ snapshots:
       expo-font: 14.0.11(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
   '@expo/ws-tunnel@1.0.6': {}
 
@@ -17897,6 +18020,11 @@ snapshots:
     dependencies:
       '@sinonjs/commons': 1.8.6
 
+  '@solidjs/testing-library@0.8.10(solid-js@1.9.11)':
+    dependencies:
+      '@testing-library/dom': 10.4.1
+      solid-js: 1.9.11
+
   '@standard-schema/spec@1.1.0': {}
 
   '@standard-schema/utils@0.3.0': {}
@@ -17933,7 +18061,7 @@ snapshots:
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
     dependencies:
       '@types/jest': 28.1.8
-      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
+      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       eslint: 8.57.1
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -18459,7 +18587,7 @@ snapshots:
       '@types/node': 22.19.6
     optional: true
 
-  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
+  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -18668,20 +18796,20 @@ snapshots:
       '@use-gesture/core': 10.3.1
       react: 19.2.4
 
-  '@vercel/analytics@1.6.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))':
+  '@vercel/analytics@1.6.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))':
     optionalDependencies:
       '@sveltejs/kit': 2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
-      next: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      next: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       react: 19.2.3
       svelte: 5.53.5
       vue: 3.5.29(typescript@5.9.2)
 
   '@vercel/oidc@3.1.0': {}
 
-  '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))':
+  '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.53.5)(vue@3.5.29(typescript@5.9.2))':
     optionalDependencies:
       '@sveltejs/kit': 2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
-      next: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      next: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       react: 19.2.3
       svelte: 5.53.5
       vue: 3.5.29(typescript@5.9.2)
@@ -18940,9 +19068,10 @@ snapshots:
 
   '@xmldom/xmldom@0.8.11': {}
 
-  '@xstate/store@3.15.0(react@19.2.4)':
+  '@xstate/store@3.15.0(react@19.2.4)(solid-js@1.9.11)':
     optionalDependencies:
       react: 19.2.4
+      solid-js: 1.9.11
 
   '@xtuc/ieee754@1.2.0': {}
 
@@ -19255,6 +19384,15 @@ snapshots:
       '@types/babel__core': 7.20.5
       '@types/babel__traverse': 7.28.0
 
+  babel-plugin-jsx-dom-expressions@0.40.5(@babel/core@7.29.0):
+    dependencies:
+      '@babel/core': 7.29.0
+      '@babel/helper-module-imports': 7.18.6
+      '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
+      '@babel/types': 7.29.0
+      html-entities: 2.3.3
+      parse5: 7.3.0
+
   babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0):
     dependencies:
       '@babel/compat-data': 7.29.0
@@ -19345,7 +19483,7 @@ snapshots:
       resolve-from: 5.0.0
     optionalDependencies:
       '@babel/runtime': 7.28.6
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
     transitivePeerDependencies:
       - '@babel/core'
       - supports-color
@@ -19362,6 +19500,13 @@ snapshots:
       babel-plugin-jest-hoist: 29.6.3
       babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
 
+  babel-preset-solid@1.9.10(@babel/core@7.29.0)(solid-js@1.9.11):
+    dependencies:
+      '@babel/core': 7.29.0
+      babel-plugin-jsx-dom-expressions: 0.40.5(@babel/core@7.29.0)
+    optionalDependencies:
+      solid-js: 1.9.11
+
   bail@2.0.2: {}
 
   balanced-match@1.0.2: {}
@@ -20301,6 +20446,16 @@ snapshots:
       esast-util-from-estree: 2.0.0
       vfile-message: 4.0.3
 
+  esbuild-plugin-solid@0.6.0(esbuild@0.27.2)(solid-js@1.9.11):
+    dependencies:
+      '@babel/core': 7.29.0
+      '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
+      babel-preset-solid: 1.9.10(@babel/core@7.29.0)(solid-js@1.9.11)
+      esbuild: 0.27.2
+      solid-js: 1.9.11
+    transitivePeerDependencies:
+      - supports-color
+
   esbuild-register@3.6.0(esbuild@0.25.12):
     dependencies:
       debug: 4.4.3
@@ -20761,6 +20916,7 @@ snapshots:
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
     transitivePeerDependencies:
       - supports-color
+    optional: true
 
   expo-clipboard@8.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
@@ -20785,6 +20941,7 @@ snapshots:
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
     transitivePeerDependencies:
       - supports-color
+    optional: true
 
   expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
     dependencies:
@@ -20795,6 +20952,7 @@ snapshots:
     dependencies:
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
   expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
@@ -20809,6 +20967,7 @@ snapshots:
       fontfaceobserver: 2.3.0
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
   expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
     dependencies:
@@ -20819,6 +20978,7 @@ snapshots:
     dependencies:
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react: 19.2.4
+    optional: true
 
   expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
@@ -20860,6 +21020,7 @@ snapshots:
       invariant: 2.2.4
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
   expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.2.4(react@19.1.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
@@ -21022,6 +21183,7 @@ snapshots:
       - graphql
       - supports-color
       - utf-8-validate
+    optional: true
 
   exponential-backoff@3.1.3: {}
 
@@ -21309,9 +21471,9 @@ snapshots:
 
   fzf@0.5.2: {}
 
-  geist@1.7.0(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)):
+  geist@1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)):
     dependencies:
-      next: 16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      next: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
 
   geist@1.7.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
     dependencies:
@@ -21634,6 +21796,8 @@ snapshots:
     transitivePeerDependencies:
       - '@exodus/crypto'
 
+  html-entities@2.3.3: {}
+
   html-escaper@2.0.2: {}
 
   html-to-text@9.0.5:
@@ -21950,6 +22114,8 @@ snapshots:
       call-bound: 1.0.4
       get-intrinsic: 1.3.0
 
+  is-what@4.1.16: {}
+
   is-windows@1.0.2: {}
 
   is-wsl@2.2.0:
@@ -23127,6 +23293,10 @@ snapshots:
 
   memoize-one@5.2.1: {}
 
+  merge-anything@5.1.7:
+    dependencies:
+      is-what: 4.1.16
+
   merge-descriptors@2.0.0: {}
 
   merge-stream@2.0.0: {}
@@ -23744,7 +23914,7 @@ snapshots:
       react: 19.2.4
       react-dom: 19.2.4(react@19.2.4)
 
-  next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+  next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
     dependencies:
       '@next/env': 16.1.1
       '@swc/helpers': 0.5.15
@@ -23753,7 +23923,7 @@ snapshots:
       postcss: 8.4.31
       react: 19.2.3
       react-dom: 19.2.3(react@19.2.3)
-      styled-jsx: 5.1.6(react@19.2.3)
+      styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3)
     optionalDependencies:
       '@next/swc-darwin-arm64': 16.1.1
       '@next/swc-darwin-x64': 16.1.1
@@ -25375,6 +25545,12 @@ snapshots:
     dependencies:
       randombytes: 2.1.0
 
+  seroval-plugins@1.5.1(seroval@1.5.1):
+    dependencies:
+      seroval: 1.5.1
+
+  seroval@1.5.1: {}
+
   serve-static@1.16.3:
     dependencies:
       encodeurl: 2.0.0
@@ -25593,6 +25769,21 @@ snapshots:
 
   smol-toml@1.6.0: {}
 
+  solid-js@1.9.11:
+    dependencies:
+      csstype: 3.2.3
+      seroval: 1.5.1
+      seroval-plugins: 1.5.1(seroval@1.5.1)
+
+  solid-refresh@0.6.3(solid-js@1.9.11):
+    dependencies:
+      '@babel/generator': 7.29.1
+      '@babel/helper-module-imports': 7.28.6
+      '@babel/types': 7.29.0
+      solid-js: 1.9.11
+    transitivePeerDependencies:
+      - supports-color
+
   sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
     dependencies:
       react: 19.2.3
@@ -25858,17 +26049,19 @@ snapshots:
     dependencies:
       inline-style-parser: 0.2.7
 
-  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
+  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
     dependencies:
       client-only: 0.0.1
-      react: 19.2.4
+      react: 19.2.3
     optionalDependencies:
       '@babel/core': 7.29.0
 
-  styled-jsx@5.1.6(react@19.2.3):
+  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
     dependencies:
       client-only: 0.0.1
-      react: 19.2.3
+      react: 19.2.4
+    optionalDependencies:
+      '@babel/core': 7.29.0
 
   sucrase@3.35.1:
     dependencies:
@@ -26631,6 +26824,19 @@ snapshots:
       rollup: 4.55.1
       vite: 6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
 
+  vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
+    dependencies:
+      '@babel/core': 7.29.0
+      '@types/babel__core': 7.20.5
+      babel-preset-solid: 1.9.10(@babel/core@7.29.0)(solid-js@1.9.11)
+      merge-anything: 5.1.7
+      solid-js: 1.9.11
+      solid-refresh: 0.6.3(solid-js@1.9.11)
+      vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+      vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+    transitivePeerDependencies:
+      - supports-color
+
   vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
     dependencies:
       esbuild: 0.25.12

+ 202 - 0
skills/solid/SKILL.md

@@ -0,0 +1,202 @@
+---
+name: solid
+description: SolidJS renderer for json-render. Use when building @json-render/solid catalogs/registries, wiring Renderer providers, implementing bindings/actions, or troubleshooting Solid-specific reactivity patterns.
+---
+
+# @json-render/solid
+
+`@json-render/solid` renders json-render specs into Solid component trees with fine-grained reactivity.
+
+## Quick Start
+
+```tsx
+import { Renderer, JSONUIProvider } from "@json-render/solid";
+import type { Spec } from "@json-render/solid";
+import { registry } from "./registry";
+
+export function App(props: { spec: Spec | null }) {
+  return (
+    <JSONUIProvider registry={registry} initialState={{}}>
+      <Renderer spec={props.spec} registry={registry} />
+    </JSONUIProvider>
+  );
+}
+```
+
+## Create a Catalog
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/solid/schema";
+import { z } from "zod";
+
+export const catalog = defineCatalog(schema, {
+  components: {
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary"]).nullable(),
+      }),
+      description: "Clickable button",
+    },
+    Card: {
+      props: z.object({ title: z.string() }),
+      description: "Card container",
+    },
+  },
+  actions: {
+    submit: { description: "Submit data" },
+  },
+});
+```
+
+## Define Components
+
+Components receive `ComponentRenderProps` from the renderer:
+
+```ts
+interface ComponentRenderProps<P = Record<string, unknown>> {
+  element: UIElement<string, P>;
+  children?: JSX.Element;
+  emit: (event: string) => void;
+  on: (event: string) => EventHandle;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+```
+
+Example:
+
+```tsx
+import type { BaseComponentProps } from "@json-render/solid";
+
+export function Button(props: BaseComponentProps<{ label: string }>) {
+  return (
+    <button onClick={() => props.emit("press")}>{props.props.label}</button>
+  );
+}
+```
+
+## Create a Registry
+
+```typescript
+import { defineRegistry } from "@json-render/solid";
+import { catalog } from "./catalog";
+import { Card } from "./Card";
+import { Button } from "./Button";
+
+const { registry, handlers, executeAction } = defineRegistry(catalog, {
+  components: {
+    Card,
+    Button,
+  },
+  actions: {
+    submit: async (params, setState, state) => {
+      // custom action logic
+    },
+  },
+});
+```
+
+## Spec Structure
+
+```json
+{
+  "root": "card1",
+  "elements": {
+    "card1": {
+      "type": "Card",
+      "props": { "title": "Hello" },
+      "children": ["btn1"]
+    },
+    "btn1": {
+      "type": "Button",
+      "props": { "label": "Click me" },
+      "on": {
+        "press": { "action": "submit" }
+      }
+    }
+  }
+}
+```
+
+## Providers
+
+- `StateProvider`: state model read/write and controlled mode via `store`
+- `VisibilityProvider`: evaluates `visible` conditions
+- `ValidationProvider`: field validation + `validateForm` integration
+- `ActionProvider`: runs built-in and custom actions
+- `JSONUIProvider`: combined provider wrapper
+
+## Hooks
+
+- `useStateStore`, `useStateValue`, `useStateBinding`
+- `useVisibility`, `useIsVisible`
+- `useActions`, `useAction`
+- `useValidation`, `useOptionalValidation`, `useFieldValidation`
+- `useBoundProp`
+- `useUIStream`, `useChatUI`
+
+## Built-in Actions
+
+Handled automatically by `ActionProvider`:
+
+- `setState`
+- `pushState`
+- `removeState`
+- `validateForm`
+
+## Dynamic Props and Bindings
+
+Supported expression forms include:
+
+- `{"$state": "/path"}`
+- `{"$bindState": "/path"}`
+- `{"$bindItem": "field"}`
+- `{"$template": "Hi ${/user/name}"}`
+- `{"$computed": "fn", "args": {...}}`
+- `{"$cond": <condition>, "$then": <value>, "$else": <value>}`
+
+Use `useBoundProp` in components for writable bound values:
+
+```tsx
+import { useBoundProp } from "@json-render/solid";
+
+function Input(props: BaseComponentProps<{ value?: string }>) {
+  const [value, setValue] = useBoundProp(
+    props.props.value,
+    props.bindings?.value,
+  );
+  return (
+    <input
+      value={String(value() ?? "")}
+      onInput={(e) => setValue(e.currentTarget.value)}
+    />
+  );
+}
+```
+
+`useStateValue`, `useStateBinding`, and the `state` / `errors` / `isValid` fields from `useFieldValidation` are reactive accessors in Solid. Call them as functions inside JSX, `createMemo`, or `createEffect`.
+
+## Solid Reactivity Rules
+
+- Do not destructure component props in function signatures when values need to stay reactive.
+- Keep changing reads inside JSX expressions, `createMemo`, or `createEffect`.
+- Context values are exposed through getter-based objects so consumers always observe live signals.
+
+## Streaming UI
+
+```tsx
+import { useUIStream, Renderer } from "@json-render/solid";
+
+const stream = useUIStream({ api: "/api/generate-ui" });
+await stream.send("Create a support dashboard");
+
+<Renderer
+  spec={stream.spec}
+  registry={registry}
+  loading={stream.isStreaming}
+/>;
+```
+
+Use `useChatUI` for chat + UI generation flows.

+ 10 - 2
vitest.config.mts

@@ -2,20 +2,28 @@ import { defineConfig } from "vitest/config";
 import path from "path";
 import { fileURLToPath } from "url";
 import { svelte } from "@sveltejs/vite-plugin-svelte";
+import solid from "vite-plugin-solid";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 export default defineConfig({
-  plugins: [svelte({ hot: false })],
+  plugins: [
+    svelte({ hot: false }),
+    solid({
+      // Only transform files in the solid package to avoid interfering with React JSX
+      include: ["packages/solid/**/*.{ts,tsx}"],
+    }),
+  ],
   resolve: {
     // Ensure Svelte resolves to browser bundle, not server
     conditions: ["browser"],
-    // Deduplicate React and Vue so tests don't get two copies
+    // Deduplicate React, Vue, and Solid 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"),
+      "solid-js": path.resolve(__dirname, "node_modules/solid-js"),
     },
   },
   test: {