瀏覽代碼

prepare v0.10 (#164)

Chris Tate 4 月之前
父節點
當前提交
9cef4e9

+ 3 - 1
.changeset/config.json

@@ -13,7 +13,9 @@
       "@json-render/codegen",
       "@json-render/zustand",
       "@json-render/redux",
-      "@json-render/jotai"
+      "@json-render/jotai",
+      "@json-render/vue",
+      "@json-render/xstate"
     ]
   ],
   "linked": [],

+ 74 - 0
.changeset/v0-10-release.md

@@ -0,0 +1,74 @@
+---
+"@json-render/core": minor
+"@json-render/react": minor
+"@json-render/shadcn": minor
+"@json-render/vue": minor
+"@json-render/xstate": minor
+---
+
+Dynamic forms, Vue renderer, XState Store adapter, and computed values.
+
+### New: `@json-render/vue` Package
+
+Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
+- `defineRegistry` — create type-safe component registries from catalogs
+- `Renderer` — render specs as Vue component trees
+- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
+- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
+- Streaming: `useUIStream`, `useChatUI`
+- External store support via `StateStore` interface
+
+### New: `@json-render/xstate` Package
+
+XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
+- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
+- Requires `@xstate/store` v3+
+
+### New: `$computed` Expressions
+
+Call registered functions from prop expressions:
+- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
+- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
+- `ComputedFunction` type exported from `@json-render/core`
+
+### New: `$template` Expressions
+
+Interpolate state values into strings:
+- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
+- Missing paths resolve to empty string
+
+### New: State Watchers
+
+React to state changes by triggering actions:
+- `watch` field on elements maps state paths to action bindings
+- Fires when watched values change (not on initial render)
+- Supports cascading dependencies (e.g. country → city loading)
+- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
+- Spec validator detects and auto-fixes `watch` placed inside props
+
+### New: Cross-Field Validation Functions
+
+New built-in validation functions for cross-field comparisons:
+- `equalTo` — alias for `matches` with clearer semantics
+- `lessThan` — value must be less than another field (numbers, strings, coerced)
+- `greaterThan` — value must be greater than another field
+- `requiredIf` — required only when a condition field is truthy
+- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
+
+### New: `validateForm` Action (React)
+
+Built-in action that validates all registered form fields at once:
+- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
+- Default state path: `/formValidation` (configurable via `statePath` param)
+- Added to React schema's built-in actions list
+
+### Improved: shadcn/ui Validation
+
+All form components now support validation:
+- Checkbox, Radio, Switch — added `checks` and `validateOn` props
+- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
+- Shared validation schemas reduce catalog definition duplication
+
+### Improved: React Provider Tree
+
+Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.

+ 23 - 3
README.md

@@ -121,7 +121,7 @@ function Dashboard({ spec }) {
 | `@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-store` | XState Store (atom) adapter for `StateStore` |
+| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
 
 ## Renderers
 
@@ -342,10 +342,12 @@ Any prop value can be data-driven using expressions:
 }
 ```
 
-Two expression forms:
+Expression forms:
 
 - **`{ "$state": "/state/key" }`** - reads a value from the state model
-- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
+- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
+- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
+- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
 
 ### Actions
 
@@ -361,6 +363,22 @@ Components can trigger actions, including the built-in `setState` action:
 
 The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
 
+### State Watchers
+
+React to state changes by triggering actions:
+
+```json
+{
+  "type": "Select",
+  "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
+  "watch": {
+    "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
+  }
+}
+```
+
+`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
+
 ---
 
 ## Demo
@@ -376,6 +394,8 @@ pnpm dev
 - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
 - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
 - Chat Example: run `pnpm dev` in `examples/chat`
+- Vue Example: run `pnpm dev` in `examples/vue`
+- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
 - React Native example: run `npx expo start` in `examples/react-native`
 
 ## How It Works

+ 30 - 0
apps/web/app/(main)/docs/api/react/page.mdx

@@ -109,6 +109,27 @@ const { registry } = defineRegistry(catalog, {
 type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
 ```
 
+### JSONUIProvider
+
+Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `functions` | `Record<string, ComputedFunction>` | Named functions for `$computed` expressions in props |
+
+```tsx
+<JSONUIProvider
+  spec={spec}
+  catalog={catalog}
+  handlers={{ submit: async () => { /* ... */ } }}
+  functions={{ fullName: (args) => `${args.first} ${args.last}` }}
+>
+  <Renderer spec={spec} registry={registry} />
+</JSONUIProvider>
+```
+
+The `functions` prop is also available on `createRenderer`.
+
 ### Component Props (via defineRegistry)
 
 ```tsx
@@ -237,6 +258,15 @@ const {
 
 `ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
 
+### useOptionalValidation
+
+Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
+
+```typescript
+const validation = useOptionalValidation();
+// ValidationContextValue | null
+```
+
 ### useBoundProp
 
 Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.

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

@@ -5,6 +5,119 @@ export const metadata = pageMetadata("docs/changelog")
 
 Notable changes and updates to json-render.
 
+## v0.10.0
+
+February 2026
+
+### New: `@json-render/vue`
+
+Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
+
+```bash
+npm install @json-render/core @json-render/vue
+```
+
+```typescript
+import { h } from "vue";
+import { defineRegistry, Renderer } from "@json-render/vue";
+import { schema } from "@json-render/vue/schema";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) =>
+      h("div", { class: "card" }, [h("h3", null, props.title), children]),
+    Button: ({ props, emit }) =>
+      h("button", { onClick: () => emit("press") }, props.label),
+  },
+});
+```
+
+Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
+
+See the [Vue API reference](/docs/api/vue) for details.
+
+### New: `@json-render/xstate`
+
+[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
+
+```bash
+npm install @json-render/xstate @xstate/store
+```
+
+```typescript
+import { createAtom } from "@xstate/store";
+import { xstateStoreStateStore } from "@json-render/xstate";
+
+const atom = createAtom({ count: 0 });
+const store = xstateStoreStateStore({ atom });
+```
+
+Requires `@xstate/store` v3+.
+
+### New: `$computed` and `$template` Expressions
+
+Two new prop expression types for dynamic values:
+
+- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
+- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
+
+Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
+
+### New: State Watchers
+
+Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
+
+```json
+{
+  "type": "Select",
+  "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
+  "watch": {
+    "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
+  }
+}
+```
+
+`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
+
+### New: Cross-Field Validation
+
+New built-in validation functions for cross-field comparisons:
+
+- `equalTo` -- alias for `matches` with clearer semantics
+- `lessThan` -- value must be less than another field
+- `greaterThan` -- value must be greater than another field
+- `requiredIf` -- required only when a condition field is truthy
+
+Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
+
+### New: `validateForm` Action
+
+Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
+
+```json
+{
+  "on": {
+    "press": [
+      { "action": "validateForm", "params": { "statePath": "/formResult" } },
+      { "action": "submitForm" }
+    ]
+  }
+}
+```
+
+### Improved: shadcn/ui Validation
+
+All form components now support `checks` and `validateOn` props:
+- Checkbox, Radio, Switch added validation support
+- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
+
+### New Examples
+
+- **Vue example** -- standalone Vue 3 app with custom components
+- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
+
+---
+
 ## v0.9.1
 
 February 2026

+ 2 - 0
apps/web/app/(main)/docs/installation/page.mdx

@@ -47,6 +47,8 @@ If you want to wire json-render to an existing state management library instead
 
 <PackageInstall packages="@json-render/jotai" />
 
+<PackageInstall packages="@json-render/xstate" />
+
 See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
 
 ## For AI Integration

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

@@ -23,7 +23,9 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/streaming": "Streaming",
   "docs/validation": "Validation",
   "docs/data-binding": "Data Binding",
+  "docs/computed-values": "Computed Values",
   "docs/visibility": "Visibility",
+  "docs/watchers": "Watchers",
   "docs/generation-modes": "Generation Modes",
   "docs/code-export": "Code Export",
   "docs/custom-schema": "Custom Schema & Renderer",

+ 114 - 0
packages/core/README.md

@@ -188,6 +188,22 @@ Schema options:
 | `resolvePropValue(value, ctx)` | Resolve a single prop expression |
 | `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element |
 | `PropExpression<T>` | Type for prop values that may contain expressions |
+| `ComputedFunction` | Function signature for `$computed` expressions |
+| `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) |
+
+### Validation
+
+| Export | Purpose |
+|--------|---------|
+| `check.required()` | Required validation helper |
+| `check.email()` | Email validation helper |
+| `check.matches(path)` | Cross-field match helper |
+| `check.equalTo(path)` | Cross-field equality helper |
+| `check.lessThan(path)` | Cross-field less-than helper |
+| `check.greaterThan(path)` | Cross-field greater-than helper |
+| `check.requiredIf(path)` | Conditional required helper |
+| `builtInValidationFunctions` | All built-in validation functions |
+| `runValidationCheck()` | Run a single validation check |
 
 ### User Prompt
 
@@ -285,6 +301,7 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j
 | `Spec` | Base spec type |
 | `Catalog` | Catalog type |
 | `BuiltInAction` | Built-in action type (`name` + `description`) |
+| `ComputedFunction` | Function signature for `$computed` expressions |
 | `VisibilityCondition` | Visibility condition type (used by `$cond`) |
 | `VisibilityContext` | Context for evaluating visibility and prop expressions |
 | `SpecStreamLine` | Single patch operation |
@@ -372,6 +389,44 @@ Get the current array index inside a repeat:
 
 `$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path).
 
+### Template (`$template`)
+
+Interpolate state values into strings using `${/path}` syntax:
+
+```json
+{
+  "label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
+}
+```
+
+Missing paths resolve to an empty string.
+
+### Computed (`$computed`)
+
+Call a registered function with resolved arguments:
+
+```json
+{
+  "text": {
+    "$computed": "fullName",
+    "args": {
+      "first": { "$state": "/form/firstName" },
+      "last": { "$state": "/form/lastName" }
+    }
+  }
+}
+```
+
+Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer.
+
+```typescript
+import type { ComputedFunction } from "@json-render/core";
+
+const functions: Record<string, ComputedFunction> = {
+  fullName: (args) => `${args.first} ${args.last}`,
+};
+```
+
 ### API
 
 ```typescript
@@ -466,6 +521,65 @@ console.log(formatSpecIssues(issues));
 const fixed = autoFixSpec(spec);
 ```
 
+## State Watchers
+
+Elements can declare a `watch` field to trigger actions when state values change. `watch` is a top-level field on the element (sibling of `type`, `props`, `children`), not inside `props`.
+
+```json
+{
+  "type": "Select",
+  "props": {
+    "label": "Country",
+    "value": { "$bindState": "/form/country" },
+    "options": ["US", "Canada", "UK"]
+  },
+  "watch": {
+    "/form/country": {
+      "action": "loadCities",
+      "params": { "country": { "$state": "/form/country" } }
+    }
+  },
+  "children": []
+}
+```
+
+Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially.
+
+## Validation
+
+### Built-in Validation Functions
+
+| Function | Description | Args |
+|----------|-------------|------|
+| `required` | Value must not be empty | — |
+| `email` | Must be a valid email | — |
+| `url` | Must be a valid URL | — |
+| `numeric` | Must be a number | — |
+| `minLength` | Minimum string length | `{ min: number }` |
+| `maxLength` | Maximum string length | `{ max: number }` |
+| `min` | Minimum numeric value | `{ min: number }` |
+| `max` | Maximum numeric value | `{ max: number }` |
+| `pattern` | Must match regex | `{ pattern: string }` |
+| `matches` | Must equal another field | `{ other: { $state: "/path" } }` |
+| `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` |
+| `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` |
+| `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` |
+| `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` |
+
+### TypeScript Helpers
+
+```typescript
+import { check } from "@json-render/core";
+
+check.required("Field is required");
+check.email("Invalid email");
+check.matches("/form/password", "Passwords must match");
+check.equalTo("/form/password", "Passwords must match");
+check.lessThan("/form/endDate", "Must be before end date");
+check.greaterThan("/form/startDate", "Must be after start date");
+check.requiredIf("/form/enableNotifications", "Required when notifications enabled");
+```
+
 ## Custom Schemas
 
 json-render supports completely different spec formats for different renderers:

+ 72 - 2
packages/react/README.md

@@ -261,6 +261,7 @@ const { errors, validate } = useFieldValidation("/form/email", {
 | `useActions()` | Access action context |
 | `useAction(name)` | Get a single action dispatch function |
 | `useFieldValidation(path, config)` | Field validation state |
+| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
 | `useUIStream(options)` | Stream specs from an API endpoint |
 
 ## Visibility Conditions
@@ -324,11 +325,60 @@ Any prop value can use data-driven expressions that resolve at render time. The
 
 For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
 
+### `$template` and `$computed`
+
+```json
+{
+  "label": { "$template": "Hello, ${/user/name}!" },
+  "fullName": {
+    "$computed": "fullName",
+    "args": {
+      "first": { "$state": "/form/firstName" },
+      "last": { "$state": "/form/lastName" }
+    }
+  }
+}
+```
+
+Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
+
+```tsx
+<JSONUIProvider
+  spec={spec}
+  catalog={catalog}
+  functions={{ fullName: (args) => `${args.first} ${args.last}` }}
+>
+```
+
 See [@json-render/core](../core/README.md) for full expression syntax.
 
+## State Watchers
+
+Elements can declare a `watch` field to trigger actions when state values change:
+
+```json
+{
+  "type": "Select",
+  "props": {
+    "label": "Country",
+    "value": { "$bindState": "/form/country" },
+    "options": ["US", "Canada", "UK"]
+  },
+  "watch": {
+    "/form/country": {
+      "action": "loadCities",
+      "params": { "country": { "$state": "/form/country" } }
+    }
+  },
+  "children": []
+}
+```
+
+`watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render.
+
 ## Built-in Actions
 
-The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
+The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`:
 
 ```json
 {
@@ -344,6 +394,26 @@ The `setState`, `pushState`, and `removeState` actions are built into the React
 }
 ```
 
+### `validateForm`
+
+Validate all registered form fields at once and write the result to state:
+
+```json
+{
+  "type": "Button",
+  "props": { "label": "Submit" },
+  "on": {
+    "press": [
+      { "action": "validateForm", "params": { "statePath": "/formResult" } },
+      { "action": "submitForm" }
+    ]
+  },
+  "children": []
+}
+```
+
+Writes `{ valid: boolean, errors: Record<string, string[]> }` to the specified state path (defaults to `/formValidation`).
+
 ## Component Props
 
 When using `defineRegistry`, components receive these props:
@@ -451,7 +521,7 @@ function App() {
 |--------|---------|
 | `defineRegistry` | Create a type-safe component registry from a catalog |
 | `Renderer` | Render a spec using a registry |
-| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
+| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
 | `useStateStore` | Access state context |
 | `useStateValue` | Get single value from state |
 | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |

+ 18 - 7
packages/shadcn/README.md

@@ -167,12 +167,12 @@ const { registry } = defineRegistry(catalog, {
 |-----------|-------------|
 | `Button` | Clickable button with variants |
 | `Link` | Anchor link |
-| `Input` | Text input with label and validation |
-| `Textarea` | Multi-line text input |
-| `Select` | Dropdown select |
-| `Checkbox` | Checkbox input |
-| `Radio` | Radio button group |
-| `Switch` | Toggle switch |
+| `Input` | Text input with label, validation, and `validateOn` timing |
+| `Textarea` | Multi-line text input with validation and `validateOn` |
+| `Select` | Dropdown select with validation and `validateOn` |
+| `Checkbox` | Checkbox input with validation and `validateOn` |
+| `Radio` | Radio button group with validation and `validateOn` |
+| `Switch` | Toggle switch with validation and `validateOn` |
 | `Slider` | Range slider |
 | `Toggle` | Toggle button |
 | `ToggleGroup` | Group of toggle buttons |
@@ -180,13 +180,24 @@ const { registry } = defineRegistry(catalog, {
 
 ## Built-in Actions
 
-State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
+State actions (`setState`, `pushState`, `removeState`, `validateForm`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
 
 | Action | Description |
 |--------|-------------|
 | `setState` | Set a value at a state path |
 | `pushState` | Push a value onto an array in state |
 | `removeState` | Remove an item from an array in state |
+| `validateForm` | Validate all fields and write result to state |
+
+### Validation Timing (`validateOn`)
+
+All form components support the `validateOn` prop to control when validation runs:
+
+| Value | Description | Default For |
+|-------|-------------|-------------|
+| `"change"` | Validate on every input change | Select, Checkbox, Radio, Switch |
+| `"blur"` | Validate when field loses focus | Input, Textarea |
+| `"submit"` | Validate only on form submission | — |
 
 ## Exports
 

+ 3 - 3
packages/xstate-store/README.md → packages/xstate/README.md

@@ -1,11 +1,11 @@
-# @json-render/xstate-store
+# @json-render/xstate
 
 [XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render.
 
 ## Installation
 
 ```bash
-npm install @json-render/xstate-store @json-render/core @json-render/react @xstate/store
+npm install @json-render/xstate @json-render/core @json-render/react @xstate/store
 ```
 
 > [!NOTE]
@@ -15,7 +15,7 @@ npm install @json-render/xstate-store @json-render/core @json-render/react @xsta
 
 ```ts
 import { createAtom } from "@xstate/store";
-import { xstateStoreStateStore } from "@json-render/xstate-store";
+import { xstateStoreStateStore } from "@json-render/xstate";
 import { StateProvider } from "@json-render/react";
 
 // 1. Create an atom

+ 2 - 2
packages/xstate-store/package.json → packages/xstate/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@json-render/xstate-store",
+  "name": "@json-render/xstate",
   "version": "0.0.0",
   "license": "Apache-2.0",
   "description": "XState Store adapter for json-render StateStore",
@@ -13,7 +13,7 @@
   "repository": {
     "type": "git",
     "url": "git+https://github.com/vercel-labs/json-render.git",
-    "directory": "packages/xstate-store"
+    "directory": "packages/xstate"
   },
   "homepage": "https://github.com/vercel-labs/json-render#readme",
   "bugs": {

+ 0 - 0
packages/xstate-store/src/index.test.ts → packages/xstate/src/index.test.ts


+ 1 - 1
packages/xstate-store/src/index.ts → packages/xstate/src/index.ts

@@ -18,7 +18,7 @@ export interface XstateStoreStateStoreOptions {
  * @example
  * ```ts
  * import { createAtom } from "@xstate/store";
- * import { xstateStoreStateStore } from "@json-render/xstate-store";
+ * import { xstateStoreStateStore } from "@json-render/xstate";
  *
  * const uiAtom = createAtom<Record<string, unknown>>({ count: 0 });
  *

+ 0 - 0
packages/xstate-store/tsconfig.json → packages/xstate/tsconfig.json


+ 0 - 0
packages/xstate-store/tsup.config.ts → packages/xstate/tsup.config.ts


+ 35 - 38
pnpm-lock.yaml

@@ -448,7 +448,7 @@ importers:
         version: 0.563.0(react@19.2.4)
       next:
         specifier: 16.1.6
-        version: 16.1.6(@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)
+        version: 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)
       radix-ui:
         specifier: ^1.4.3
         version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -722,7 +722,7 @@ importers:
         version: 6.0.91(zod@4.3.6)
       next:
         specifier: ^16.1.6
-        version: 16.1.6(@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)
+        version: 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)
       react:
         specifier: ^19.1.0
         version: 19.2.4
@@ -1232,7 +1232,7 @@ importers:
         specifier: ^3.5.0
         version: 3.5.29(typescript@5.9.2)
 
-  packages/xstate-store:
+  packages/xstate:
     dependencies:
       '@json-render/core':
         specifier: workspace:*
@@ -1246,10 +1246,10 @@ importers:
         version: 3.15.0(react@19.2.4)
       tsup:
         specifier: ^8.0.2
-        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.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.2
+        version: 5.9.3
 
   packages/zustand:
     dependencies:
@@ -16574,7 +16574,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@9.39.2(jiti@2.6.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@8.57.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)
@@ -16929,7 +16929,7 @@ snapshots:
       '@types/node': 22.19.6
     optional: true
 
-  '@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/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)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -22106,32 +22106,6 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
-  next@16.1.6(@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:
-      '@next/env': 16.1.6
-      '@swc/helpers': 0.5.15
-      baseline-browser-mapping: 2.9.14
-      caniuse-lite: 1.0.30001764
-      postcss: 8.4.31
-      react: 19.2.4
-      react-dom: 19.2.4(react@19.2.4)
-      styled-jsx: 5.1.6(react@19.2.4)
-    optionalDependencies:
-      '@next/swc-darwin-arm64': 16.1.6
-      '@next/swc-darwin-x64': 16.1.6
-      '@next/swc-linux-arm64-gnu': 16.1.6
-      '@next/swc-linux-arm64-musl': 16.1.6
-      '@next/swc-linux-x64-gnu': 16.1.6
-      '@next/swc-linux-x64-musl': 16.1.6
-      '@next/swc-win32-arm64-msvc': 16.1.6
-      '@next/swc-win32-x64-msvc': 16.1.6
-      '@opentelemetry/api': 1.9.0
-      babel-plugin-react-compiler: 1.0.0
-      sharp: 0.34.5
-    transitivePeerDependencies:
-      - '@babel/core'
-      - babel-plugin-macros
-
   node-abi@3.87.0:
     dependencies:
       semver: 7.7.3
@@ -24152,11 +24126,6 @@ snapshots:
       client-only: 0.0.1
       react: 19.2.3
 
-  styled-jsx@5.1.6(react@19.2.4):
-    dependencies:
-      client-only: 0.0.1
-      react: 19.2.4
-
   sucrase@3.35.1:
     dependencies:
       '@jridgewell/gen-mapping': 0.3.13
@@ -24464,6 +24433,34 @@ snapshots:
       - tsx
       - yaml
 
+  tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
+    dependencies:
+      bundle-require: 5.1.0(esbuild@0.27.2)
+      cac: 6.7.14
+      chokidar: 4.0.3
+      consola: 3.4.2
+      debug: 4.4.3
+      esbuild: 0.27.2
+      fix-dts-default-cjs-exports: 1.0.1
+      joycon: 3.1.1
+      picocolors: 1.1.1
+      postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
+      resolve-from: 5.0.0
+      rollup: 4.55.1
+      source-map: 0.7.6
+      sucrase: 3.35.1
+      tinyexec: 0.3.2
+      tinyglobby: 0.2.15
+      tree-kill: 1.2.2
+    optionalDependencies:
+      postcss: 8.5.6
+      typescript: 5.9.3
+    transitivePeerDependencies:
+      - jiti
+      - supports-color
+      - tsx
+      - yaml
+
   tsutils@3.21.0(typescript@4.9.5):
     dependencies:
       tslib: 1.14.1

+ 45 - 0
skills/json-render-core/SKILL.md

@@ -85,6 +85,8 @@ Any prop value can be a dynamic expression resolved at render time:
 - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
 - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
 - **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a visibility condition and picks a branch
+- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates `${/path}` references with state values
+- **`{ "$computed": "fnName", "args": { "key": <expression> } }`** - calls a registered function with resolved args
 
 `$cond` uses the same syntax as visibility conditions (`$state`, `eq`, `neq`, `not`, arrays for AND). `$then` and `$else` can themselves be expressions (recursive).
 
@@ -96,6 +98,14 @@ Components do not use a `statePath` prop for two-way binding. Instead, use `{ "$
     "$cond": { "$state": "/activeTab", "eq": "home" },
     "$then": "#007AFF",
     "$else": "#8E8E93"
+  },
+  "label": { "$template": "Welcome, ${/user/name}!" },
+  "fullName": {
+    "$computed": "fullName",
+    "args": {
+      "first": { "$state": "/form/firstName" },
+      "last": { "$state": "/form/lastName" }
+    }
   }
 }
 ```
@@ -106,6 +116,39 @@ import { resolvePropValue, resolveElementProps } from "@json-render/core";
 const resolved = resolveElementProps(element.props, { stateModel: myState });
 ```
 
+## State Watchers
+
+Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change:
+
+```json
+{
+  "type": "Select",
+  "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
+  "watch": {
+    "/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
+  },
+  "children": []
+}
+```
+
+Watchers only fire on value changes, not on initial render.
+
+## Validation
+
+Built-in validation functions: `required`, `email`, `url`, `numeric`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `equalTo`, `lessThan`, `greaterThan`, `requiredIf`.
+
+Cross-field validation uses `$state` expressions in args:
+
+```typescript
+import { check } from "@json-render/core";
+
+check.required("Field is required");
+check.matches("/form/password", "Passwords must match");
+check.lessThan("/form/endDate", "Must be before end date");
+check.greaterThan("/form/startDate", "Must be after start date");
+check.requiredIf("/form/enableNotifications", "Required when enabled");
+```
+
 ## User Prompt Builder
 
 Build structured user prompts with optional spec refinement and state context:
@@ -208,5 +251,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
 | `parseSpecStreamLine` | Parse single JSONL line |
 | `applySpecStreamPatch` | Apply patch to object |
 | `StateStore` | Interface for plugging in external state management |
+| `ComputedFunction` | Function signature for `$computed` expressions |
+| `check` | TypeScript helpers for creating validation checks |
 | `BuiltInAction` | Type for built-in action definitions (`name` + `description`) |
 | `ActionBinding` | Action binding type (includes `preventDefault` field) |

+ 29 - 2
skills/json-render-react/SKILL.md

@@ -119,6 +119,8 @@ Any prop value can be a data-driven expression resolved by the renderer before c
 - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
 - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
 - **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
+- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
+- **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args
 
 ```json
 {
@@ -134,6 +136,14 @@ Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState
 
 Components receive already-resolved props. For two-way bound props, use the `useBoundProp` hook with the `bindings` map the renderer provides.
 
+Register `$computed` functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
+
+```tsx
+<JSONUIProvider
+  functions={{ fullName: (args) => `${args.first} ${args.last}` }}
+>
+```
+
 ## Event System
 
 Components use `emit` to fire named events, or `on()` to get an event handle with metadata. The element's `on` field maps events to action bindings:
@@ -166,16 +176,32 @@ Link: ({ props, on }) => {
 
 The `EventHandle` returned by `on()` has: `emit()`, `shouldPreventDefault` (boolean), and `bound` (boolean).
 
+## State Watchers
+
+Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change:
+
+```json
+{
+  "type": "Select",
+  "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
+  "watch": { "/form/country": { "action": "loadCities" } },
+  "children": []
+}
+```
+
 ## Built-in Actions
 
-The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`:
+The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`:
 
 ```json
 { "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } }
 { "action": "pushState", "params": { "statePath": "/items", "value": { "text": "New" } } }
 { "action": "removeState", "params": { "statePath": "/items", "index": 0 } }
+{ "action": "validateForm", "params": { "statePath": "/formResult" } }
 ```
 
+`validateForm` validates all registered fields and writes `{ valid, errors }` to state.
+
 Note: `statePath` in action params (e.g. `setState.statePath`) targets the mutation path. Two-way binding in component props uses `{ "$bindState": "/path" }` on the value prop, not `statePath`.
 
 ## useBoundProp
@@ -223,12 +249,13 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
 |--------|---------|
 | `defineRegistry` | Create a type-safe component registry from a catalog |
 | `Renderer` | Render a spec using a registry |
-| `schema` | Element tree schema (includes built-in state actions) |
+| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) |
 | `useStateStore` | Access state context |
 | `useStateValue` | Get single value from state |
 | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
 | `useActions` | Access actions context |
 | `useAction` | Get a single action dispatch function |
+| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) |
 | `useUIStream` | Stream specs from an API endpoint |
 | `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
 | `StateStore` | Interface for plugging in external state management |

+ 12 - 4
skills/json-render-shadcn/SKILL.md

@@ -126,9 +126,9 @@ const { registry } = defineRegistry(catalog, {
 - **Input** - Text input with label, name, type, placeholder, value, checks
 - **Textarea** - Multi-line input with label, name, placeholder, rows, value, checks
 - **Select** - Dropdown select with label, name, options (string[]), value, checks
-- **Checkbox** - Checkbox with label, name, checked
-- **Radio** - Radio group with label, name, options (string[]), value
-- **Switch** - Toggle switch with label, name, checked
+- **Checkbox** - Checkbox with label, name, checked, checks, validateOn
+- **Radio** - Radio group with label, name, options (string[]), value, checks, validateOn
+- **Switch** - Toggle switch with label, name, checked, checks, validateOn
 - **Slider** - Range slider with label, min, max, step, value
 - **Toggle** - Toggle button with label, pressed, variant
 - **ToggleGroup** - Group of toggles with items, type, value
@@ -141,11 +141,19 @@ These are built into the React schema and handled by `ActionProvider` automatica
 - **setState** - Set a value at a state path (`{ statePath, value }`)
 - **pushState** - Push a value onto an array (`{ statePath, value, clearStatePath? }`)
 - **removeState** - Remove an array item by index (`{ statePath, index }`)
+- **validateForm** - Validate all fields, write `{ valid, errors }` to state (`{ statePath? }`)
+
+## Validation Timing (`validateOn`)
+
+All form components support `validateOn` to control when validation runs:
+- `"change"` — validate on every input change (default for Select, Checkbox, Radio, Switch)
+- `"blur"` — validate when field loses focus (default for Input, Textarea)
+- `"submit"` — validate only on form submission
 
 ## Important Notes
 
 - The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
 - Components use Tailwind CSS classes -- your app must have Tailwind configured
 - Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
-- Form inputs support `checks` for validation (type + message pairs)
+- All form inputs support `checks` for validation (type + message pairs) and `validateOn` for timing
 - Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`