Compare commits

..
Author SHA1 Message Date
github-actions[bot] c2b397510e chore: version packages (#166)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 10:56:29 -06:00
Chris Tate 8506cfaa03 fix pkg name (#165)
* fix pkg name

* faster builds

* fixes

* Revert "fixes"

This reverts commit d0b43db972.

* Revert "faster builds"

This reverts commit 34a5190b07.
2026-02-25 10:50:59 -06:00
Chris Tate 9cef4e9142 prepare v0.10 (#164) 2026-02-25 10:27:25 -06:00
Chris Tate 3c11f19be4 vue improvements (#163)
* vue improvements

* fixes

* fixes

* fixes

* fix ci

* fix ci
2026-02-25 09:44:57 -06:00
Anthony Fu db3a8b41e9 feat: add Vue renderer (#162)
* feat: vue support

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
2026-02-25 08:21:11 -06:00
Chris Tate ea47b66dfc add dynamic forms support: computed values, watchers, cross-field validation (#156)
* add dynamic forms support: computed values, watchers, cross-field validation

- `$computed` and `$template` prop expressions for derived values and string interpolation
- Element-level `watch` field for cascading state dependencies
- Cross-field validators (`lessThan`, `greaterThan`, `equalTo`, `requiredIf`) with deep arg resolution
- `validateForm` built-in action for form-level validation

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

* fixes
2026-02-24 23:04:48 -06:00
Chris Tate cd82f969c8 xstate doc/test updates (#160) 2026-02-24 22:09:42 -06:00
David KhourshidandClaude Opus 4.6 6bcaaad57d feat: Add @xstate/store (atom) support (#157)
* feat: add @xstate/store integration using atoms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for @json-render/xstate-store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename xstateStoreStateStore to xstateStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* README updates

* Keep naming convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:01:33 -06:00
github-actions[bot] 0b7d767cdd chore: version packages (#154)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 06:29:45 -06:00
Chris Tate b1036763d2 fixed: Install failure due to private dependency (#153)
* fix @internal/react-state import

* add changeset for @internal/react-state install fix

* docs: add v0.9.1 changelog entry
2026-02-24 06:25:34 -06:00
github-actions[bot] c502d5517e chore: version packages (#149)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 01:36:59 -06:00
Chris Tate 8740deb018 Update config.json (#148) 2026-02-24 01:34:16 -06:00
Chris Tate 1d755c104a prepare v0.9.0 (#147) 2026-02-24 01:21:02 -06:00
Chris Tate d904d45150 fix schema import to use server-safe subpath (#146)
- `@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`)
- Updated all docs, READMEs, examples, and skills to import `schema` from `@json-render/react/schema` instead of `@json-render/react`
- For combined imports, split into separate `schema` (subpath) and client API (main entry) lines

Fixes #123
2026-02-24 00:55:33 -06:00
Chris Tate a110c6e0ea fix chaining actions (#144)
* fix chaining actions

* improvements
2026-02-24 00:40:40 -06:00
Juzisuan965 7de08ccdf7 fix: safely resolve inner type for Zod arrays (#140) 2026-02-24 00:02:04 -06:00
Chris Tate 3201854481 external store adapter for state management (#139)
* external store adapter for state management

Introduces a `StateStore` interface that lets users plug in their own state management (Redux, Zustand, XState, etc.) instead of being locked into the internal `useState`-based store.

- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf

* improvements

* update docs

* improvements

* fixes

* fix CI

* add store adapters

* fixes

* fixes

* fixes

* fixes

* e2e tests

* improvements

* fixes

* fixes

* fixes

* fixes

* update lockfile for widened react peer deps

* fix dashboard build
2026-02-23 23:12:57 -06:00
Chris Tate 64c889221e fix playground og (#138) 2026-02-22 16:10:31 -06:00
Chris Tate 49838fa353 update og font (#137) 2026-02-22 16:02:07 -06:00
Chris Tate fa47b08869 update header font (#136) 2026-02-22 15:51:54 -06:00
Chris Tate 5ccb109c08 use visual-json (#135)
* use visual-json

* fix lint
2026-02-22 15:42:29 -06:00
Chris Tate 62932f6516 fix gitignore (#134) 2026-02-22 15:20:33 -06:00
239 changed files with 17139 additions and 1158 deletions
+7 -2
View File
@@ -10,7 +10,12 @@
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
"@json-render/codegen",
"@json-render/zustand",
"@json-render/redux",
"@json-render/jotai",
"@json-render/vue",
"@json-render/xstate"
]
],
"linked": [],
@@ -18,7 +23,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"version": true,
"tag": false
}
}
+16
View File
@@ -27,6 +27,21 @@ This ensures we don't install outdated versions that may have incompatible types
- Do not use emojis in code or UI
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
## AI SDK / AI Gateway
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
```ts
import { streamText } from "ai";
const result = streamText({
model: "anthropic/claude-haiku-4.5",
prompt: "...",
});
```
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
## Dev Servers
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
@@ -53,6 +68,7 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
+57 -8
View File
@@ -5,15 +5,18 @@
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash
# for React
npm install @json-render/core @json-render/react
# pre-built shadcn/ui components
# for React with pre-built shadcn/ui components
npm install @json-render/shadcn
# or for mobile
# or for React Native
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for Vue
npm install @json-render/core @json-render/vue
```
## Why json-render?
@@ -23,7 +26,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
- **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -32,7 +35,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -110,10 +113,15 @@ function Dashboard({ spec }) {
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@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` |
## Renderers
@@ -121,7 +129,7 @@ function Dashboard({ spec }) {
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
// Flat spec format (root key + elements map)
const spec = {
@@ -145,11 +153,32 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Vue (UI)
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
```
### shadcn/ui (Web)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
@@ -313,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
@@ -332,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
@@ -347,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
+28
View File
@@ -0,0 +1,28 @@
# web
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+1 -1
View File
@@ -67,7 +67,7 @@ A2UI uses an adjacency list model - a flat list of components with ID references
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// A2UI BoundValue schema
@@ -90,7 +90,7 @@ Define a catalog matching the Adaptive Cards element types:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// Common Adaptive Cards properties
+1 -1
View File
@@ -151,7 +151,7 @@ Create a catalog for UI components that agents can render:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const aguiCatalog = defineCatalog(schema, {
+2 -2
View File
@@ -11,7 +11,7 @@ Creates a type-safe catalog definition with schema validation.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
function defineCatalog<T extends ZodType>(
s: T,
@@ -119,7 +119,7 @@ The schema for flat UI element trees. This is exported from @json-render/react.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
@@ -63,11 +63,36 @@ React Native renderer with standard components, providers, and hooks.
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={fn}>
{children}
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-native";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — components re-render automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -108,14 +108,17 @@ const PDFRenderer = createRenderer(catalog, components);
```typescript
interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (path: string, value: unknown) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
```
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
## Renderer
The main component that renders a spec to `@react-pdf/renderer` elements.
@@ -344,6 +347,19 @@ Renders current page number and total pages. Format uses `{pageNumber}` and `{to
}
```
## External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-pdf";
const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);
```
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
## Server-Safe Import
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
+56 -1
View File
@@ -10,11 +10,36 @@ React components, providers, and hooks.
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={fn}>
{children}
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -84,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
@@ -212,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.
+246
View File
@@ -0,0 +1,246 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/vue")
# @json-render/vue
Vue 3 components, providers, and composables.
## Providers
### StateProvider
```vue
<StateProvider :initial-state="object" :on-state-change="fn">
<!-- children -->
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue re-renders automatically:
store.set("/count", 1);
```
### ActionProvider
```vue
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
<!-- children -->
</ActionProvider>
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
```
### VisibilityProvider
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```vue
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
<!-- children -->
</ValidationProvider>
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
```
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
// Required when catalog declares actions:
actions: {
submit: async (params) => { /* ... */ },
},
});
// Pass to <Renderer>
// <Renderer :spec="spec" :registry="registry" />
```
## Components
### Renderer
```vue
<Renderer
:spec="Spec" // The UI spec to render
:registry="Registry" // Component registry (from defineRegistry)
:loading="boolean" // Optional loading state
:fallback="Component" // Optional fallback for unknown types
/>
```
### Component Props (via defineRegistry)
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Composables
### useStateStore
```typescript
const {
state, // ShallowRef<StateModel> — access with state.value
get, // (path: string) => unknown
set, // (path: string, value: unknown) => void
update, // (updates: Record<string, unknown>) => void
} = useStateStore();
```
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
### useStateValue
```typescript
const value = useStateValue(path: string); // ComputedRef<T | undefined>
```
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
### useStateBinding (deprecated)
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
// value: ComputedRef<T | undefined>
// setValue: (value: T) => void
```
### useActions
```typescript
const { execute } = useActions();
// execute(binding: ActionBinding) => Promise<void>
```
### useAction
```typescript
const { execute, isLoading } = useAction(binding: ActionBinding);
// execute: () => Promise<void>
// isLoading: ComputedRef<boolean>
```
### useIsVisible
```typescript
const isVisible = useIsVisible(condition?: VisibilityCondition);
```
### useFieldValidation
```typescript
const {
state, // ComputedRef<FieldValidationState>
validate, // () => ValidationResult
touch, // () => void
clear, // () => void
errors, // ComputedRef<string[]>
isValid, // ComputedRef<boolean>
} = useFieldValidation(path: string, config?: ValidationConfig);
```
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
## Differences from `@json-render/react`
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
+1 -1
View File
@@ -19,7 +19,7 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react'; // or '@json-render/react-native'
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
+177
View File
@@ -5,6 +5,183 @@ 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
### Fixed: Install failure due to private dependency
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
---
## v0.9.0
February 2026
### New: External State Store
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf
- Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
### Changed: `onStateChange` signature updated (breaking)
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
```ts
// Before
onStateChange?: (path: string, value: unknown) => void
// After
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
```
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
### Fixed: Server-safe schema import
`@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`.
For combined imports, split into separate `schema` (subpath) and client API (main entry) lines:
```ts
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
```
### Fixed: Chaining actions
Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`.
### Fixed: Zod array inner type resolution
Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`.
---
## v0.8.0
February 2026
@@ -0,0 +1,119 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference
<div className="my-6 overflow-x-auto">
@@ -255,10 +271,39 @@ The condition uses the same [visibility](/docs/visibility) expression format.
<td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td>
</tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody>
</table>
</div>
## External Store (Controlled Mode)
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ user: { name: "Alice" } });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/user/name", "Bob");
```
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
## Next
- [Visibility](/docs/visibility) — conditionally show or hide elements
+22 -5
View File
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
<PackageInstall packages="react zod" />
## For Vue
<PackageInstall packages="@json-render/core @json-render/vue" />
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
<PackageInstall packages="vue zod" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
@@ -25,14 +37,19 @@ Requires Tailwind CSS in your project. See the [@json-render/shadcn API referenc
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
## Peer Dependencies
## For External State Management (Optional)
json-render requires the following peer dependencies:
If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:
- `react` ^19.0.0
- `zod` ^4.0.0
<PackageInstall packages="@json-render/zustand" />
<PackageInstall packages="react zod" />
<PackageInstall packages="@json-render/redux" />
<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
+1 -1
View File
@@ -100,7 +100,7 @@ Create components that map to OpenAPI data types:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const openapiCatalog = defineCatalog(schema, {
+1 -1
View File
@@ -23,7 +23,7 @@ A catalog declares what AI can use: components with typed props, actions with ty
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
@@ -12,7 +12,7 @@ Create a catalog that defines what components AI can use:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
+1 -1
View File
@@ -133,7 +133,7 @@ Actions are declared in your [catalog](/docs/catalog). The `@json-render/react`
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
+115 -8
View File
@@ -11,11 +11,18 @@ json-render includes common validation functions:
- `required` — Value must be non-empty
- `email` — Valid email format
- `minLength` — Minimum string length
- `maxLength` — Maximum string length
- `pattern` — Match a regex pattern
- `min` — Minimum numeric value
- `max` — Maximum numeric value
- `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
## Using Validation in JSON
@@ -67,7 +74,7 @@ Define custom validators in your catalog's `functions` field. The catalog itself
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react'; // or '@json-render/react-native'
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing
Control when validation runs with `validateOn`:
- `change` — Validate on every input change
- `blur` — Validate when field loses focus
- `blur` — Validate when field loses focus (default for Input, Textarea)
- `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next
Learn about [generation modes](/docs/generation-modes).
- [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
+150
View File
@@ -0,0 +1,150 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`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": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
@@ -91,7 +92,9 @@ export default async function RootLayout({
/>
)}
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
>
<ThemeProvider>
{children}
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
+15 -8
View File
@@ -5,19 +5,20 @@ import { join } from "node:path";
export { getPageTitle } from "@/lib/page-titles";
// Cache font data in memory after first load
let fontCache: { geistRegular: Buffer } | null = null;
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
async function loadFonts() {
if (fontCache) return fontCache;
const geistRegular = await readFile(
join(process.cwd(), "public/Geist-Regular.ttf"),
);
fontCache = { geistRegular };
const [geistRegular, geistPixelSquare] = await Promise.all([
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
]);
fontCache = { geistRegular, geistPixelSquare };
return fontCache;
}
export async function renderOgImage(title: string) {
const { geistRegular } = await loadFonts();
const { geistRegular, geistPixelSquare } = await loadFonts();
return new ImageResponse(
<div
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
<span
style={{
fontSize: 36,
fontFamily: "Geist",
fontWeight: 400,
fontFamily: "Geist Pixel Square",
fontWeight: 500,
color: "white",
}}
>
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
style: "normal",
weight: 400,
},
{
name: "Geist Pixel Square",
data: geistPixelSquare.buffer as ArrayBuffer,
style: "normal",
weight: 500,
},
],
},
);
+2 -5
View File
@@ -1,10 +1,7 @@
import { Playground } from "@/components/playground";
import { pageMetadata } from "@/lib/page-metadata";
import { PAGE_TITLES } from "@/lib/page-titles";
export const metadata = {
title: PAGE_TITLES["playground"],
};
export const metadata = pageMetadata("playground");
export default function PlaygroundPage() {
return <Playground />;
+1 -1
View File
@@ -57,7 +57,7 @@ export function Header() {
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
json-render
</span>
</Link>
+122 -31
View File
@@ -16,17 +16,20 @@ import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
import { JsonEditor } from "@visual-json/react";
import type { JsonValue } from "@visual-json/react";
import { PlaygroundRenderer } from "@/lib/render/renderer";
import { playgroundCatalog } from "@/lib/render/catalog";
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
type Tab = "json" | "nested" | "stream" | "catalog";
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
type RenderView = "preview" | "code";
type MobileView =
| "json"
| "nested"
| "stream"
| "catalog"
| "visual"
| "preview"
| "generated-code";
@@ -233,6 +236,20 @@ export function Playground() {
[handleSubmit],
);
const handleVisualChange = useCallback(
(value: JsonValue) => {
if (!selectedVersionId || isStreaming) return;
setVersions((prev) =>
prev.map((v) =>
v.id === selectedVersionId
? { ...v, tree: value as unknown as Spec }
: v,
),
);
},
[selectedVersionId, isStreaming],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
@@ -479,31 +496,69 @@ ${jsx}
? jsonCode
: activeTab === "nested"
? nestedCode
: "";
: activeTab === "visual"
? jsonCode
: "";
const codePane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{activeTab !== "catalog" && (
{activeTab !== "catalog" && activeTab !== "visual" && (
<CopyButton text={copyText} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{activeTab === "catalog" ? (
{activeTab === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : activeTab === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
@@ -735,19 +790,21 @@ ${jsx}
: 0}
</button>
{/* Code tabs */}
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{/* Preview / code toggle */}
{[
@@ -770,7 +827,41 @@ ${jsx}
{/* Main content area */}
<div className="flex-1 min-h-0 overflow-auto">
{mobileView === "catalog" ? (
{mobileView === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : mobileView === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+13
View File
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" },
],
},
@@ -67,6 +69,16 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
external: true,
},
{
title: "Renders with Vite (Vue / React)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
],
},
{
@@ -95,6 +107,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
+3
View File
@@ -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",
@@ -38,6 +40,7 @@ export const PAGE_TITLES: Record<string, string> = {
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"license": "Apache-2.0",
@@ -28,11 +28,13 @@
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@visual-json/react": "0.1.1",
"ai": "^6.0.33",
"bash-tool": "1.3.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
"just-bash": "2.9.6",
"lucide-react": "^0.562.0",
"next": "16.1.1",
@@ -51,8 +53,8 @@
"zod": "^4.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^22.15.3",
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"extends": "@internal/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
+28
View File
@@ -0,0 +1,28 @@
# example-chat
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/shadcn@0.9.0
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
@@ -36,7 +36,7 @@
"zod": "4.3.5"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+28
View File
@@ -0,0 +1,28 @@
# example-dashboard
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+21 -17
View File
@@ -196,24 +196,28 @@ export function Widget({
onDeleted?.();
}, [widgetId, onDeleted]);
const handleStateChange = useCallback((path: string, value: unknown) => {
setState((prev) => {
const next = { ...prev };
// Convert path like "customerForm/name" to nested object
const parts = path.split("/");
let current: Record<string, unknown> = next;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
const handleStateChange = useCallback(
(changes: Array<{ path: string; value: unknown }>) => {
setState((prev) => {
const next = { ...prev };
for (const { path, value } of changes) {
const parts = path.split("/");
let current: Record<string, unknown> = next;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1]!;
current[lastPart] = value;
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1]!;
current[lastPart] = value;
return next;
});
}, []);
return next;
});
},
[],
);
// Use spec from stream, or initial spec for saved widgets
const currentSpec = spec || initialSpec;
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+1 -1
View File
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
spec: Spec | null;
state?: Record<string, unknown>;
setState?: SetState;
onStateChange?: (path: string, value: unknown) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
@@ -42,7 +42,7 @@
"zod": "^4.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+28
View File
@@ -0,0 +1,28 @@
# example-no-ai
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/shadcn@0.9.0
+59 -41
View File
@@ -2,33 +2,31 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import {
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import { JSONUIProvider, Renderer } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
import {
registry,
actionHandlers,
computedFunctions,
onConfetti,
} from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
<JSONUIProvider
registry={registry}
initialState={spec.state ?? {}}
handlers={actionHandlers}
functions={computedFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
);
}
export default function Page() {
const [selectedIndex] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
@@ -41,30 +39,50 @@ export default function Page() {
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex items-center justify-center bg-muted/30">
<div
className="relative bg-background border rounded-lg shadow-sm"
style={{ width: 960, height: 1080 }}
>
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
<div className="h-screen flex flex-col bg-muted/30">
{/* Example selector */}
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
{examples.map((ex, i) => (
<button
key={ex.name}
onClick={() => setSelectedIndex(i)}
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
i === selectedIndex
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
{ex.name}
</button>
))}
</nav>
{/* Render area */}
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="p-6 relative z-10">
<p className="text-xs text-muted-foreground mb-4">
{selected.description}
</p>
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+349
View File
@@ -508,4 +508,353 @@ export const examples: Example[] = [
},
},
},
// =========================================================================
// Advanced: Registration form with cross-field validation & $template
// =========================================================================
{
name: "Registration Form",
description:
"Cross-field validation, $template preview, and validateForm action",
spec: {
root: "card",
state: {
form: {
name: "",
email: "",
password: "",
confirmPassword: "",
accountType: "personal",
company: "",
},
result: null,
},
elements: {
card: {
type: "Card",
props: {
title: "Create Account",
description: "Fill out the form below to register",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"preview",
"sep0",
"nameInput",
"emailInput",
"passwordInput",
"confirmInput",
"sep1",
"accountTypeRadio",
"companyInput",
"sep2",
"actions",
"statusText",
],
},
// $template live preview
preview: {
type: "Text",
props: {
text: {
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
},
variant: "muted",
},
visible: { $state: "/form/name", neq: "" },
children: [],
},
sep0: { type: "Separator", props: { orientation: null }, children: [] },
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Jane Doe",
value: { $bindState: "/form/name" },
checks: [
{ type: "required", message: "Name is required" },
{
type: "minLength",
args: { min: 2 },
message: "Name must be at least 2 characters",
},
],
validateOn: "blur",
},
children: [],
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "jane@example.com",
value: { $bindState: "/form/email" },
checks: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email address" },
],
validateOn: "blur",
},
children: [],
},
passwordInput: {
type: "Input",
props: {
label: "Password",
name: "password",
type: "password",
placeholder: "At least 8 characters",
value: { $bindState: "/form/password" },
checks: [
{ type: "required", message: "Password is required" },
{
type: "minLength",
args: { min: 8 },
message: "Password must be at least 8 characters",
},
],
validateOn: "blur",
},
children: [],
},
confirmInput: {
type: "Input",
props: {
label: "Confirm Password",
name: "confirmPassword",
type: "password",
placeholder: "Re-enter your password",
value: { $bindState: "/form/confirmPassword" },
checks: [
{ type: "required", message: "Please confirm your password" },
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
],
validateOn: "blur",
},
children: [],
},
sep1: { type: "Separator", props: { orientation: null }, children: [] },
accountTypeRadio: {
type: "Radio",
props: {
label: "Account Type",
name: "accountType",
options: ["personal", "business"],
value: { $bindState: "/form/accountType" },
checks: null,
validateOn: null,
},
children: [],
},
companyInput: {
type: "Input",
props: {
label: "Company Name",
name: "company",
type: "text",
placeholder: "Acme Inc.",
value: { $bindState: "/form/company" },
checks: [
{
type: "requiredIf",
args: { field: { $state: "/form/accountType" } },
message: "Company name is required for business accounts",
},
],
validateOn: "blur",
},
visible: { $state: "/form/accountType", eq: "business" },
children: [],
},
sep2: { type: "Separator", props: { orientation: null }, children: [] },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["submitBtn"],
},
submitBtn: {
type: "Button",
props: { label: "Register", variant: "primary", disabled: null },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
// Validation result
statusText: {
type: "Alert",
props: {
title: "Validation Result",
message: {
$cond: { $state: "/result/valid", eq: true },
$then: "All fields are valid -- ready to submit!",
$else: "Please fix the errors above before submitting.",
},
type: {
$cond: { $state: "/result/valid", eq: true },
$then: "success",
$else: "error",
},
},
visible: { $state: "/result", neq: null },
children: [],
},
},
},
},
// =========================================================================
// Advanced: Cascading selects with watchers & $computed
// =========================================================================
{
name: "Cascading Selects",
description:
"Watchers reset dependent fields, $computed derives display values",
spec: {
root: "card",
state: {
form: { country: "", city: "" },
availableCities: [],
},
elements: {
card: {
type: "Card",
props: {
title: "Shipping Address",
description: "Select your country to load available cities",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"countrySelect",
"citySelect",
"sep",
"addressPreview",
"templatePreview",
],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK", "Germany", "Japan"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
watch: {
"/form/country": [
{
action: "setState",
params: {
statePath: "/availableCities",
value: {
$computed: "citiesForCountry",
args: { country: { $state: "/form/country" } },
},
},
},
{
action: "setState",
params: { statePath: "/form/city", value: "" },
},
],
},
children: [],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: { $state: "/availableCities" },
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
sep: { type: "Separator", props: { orientation: null }, children: [] },
// $computed formatted address
addressPreview: {
type: "Heading",
props: {
text: {
$computed: "formatAddress",
args: {
city: { $state: "/form/city" },
country: { $state: "/form/country" },
},
},
level: "h3",
},
children: [],
},
// $template string interpolation
templatePreview: {
type: "Text",
props: {
text: {
$template:
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
},
variant: "muted",
},
visible: { $state: "/form/country", neq: "" },
children: [],
},
},
},
},
];
+9
View File
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
description: "Fire confetti",
},
},
functions: {
formatAddress: {
description:
"Formats country and city into a single address string like 'City, Country'",
},
citiesForCountry: {
description: "Returns an array of city names for the given country code",
},
},
});
+27 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { defineRegistry } from "@json-render/react";
import type { ComputedFunction } from "@json-render/core";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
};
}
const cityData: Record<string, string[]> = {
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
};
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
},
});
export const actionHandlers: Record<string, () => void> = {
export const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
confetti: () => confettiListener?.(),
};
export const computedFunctions: Record<string, ComputedFunction> = {
formatAddress: (args) => {
const city = (args.city as string) ?? "";
const country = (args.country as string) ?? "";
if (!city && !country) return "No location selected";
if (!city) return country;
return `${city}, ${country}`;
},
citiesForCountry: (args) => {
const country = (args.country as string) ?? "";
return cityData[country] ?? [];
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
@@ -26,7 +26,7 @@
"zod": "4.3.5"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+25
View File
@@ -0,0 +1,25 @@
# example-react-native
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react-native@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-native@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react-native@0.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.0",
"version": "0.1.3",
"private": true,
"main": "expo-router/entry",
"scripts": {
+25
View File
@@ -0,0 +1,25 @@
# example-react-pdf
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react-pdf@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-pdf@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react-pdf@0.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
@@ -28,7 +28,7 @@
"zod": "4.3.5"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
+24
View File
@@ -0,0 +1,24 @@
# example-remotion
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/remotion@0.10.0
## 0.1.2
### Patch Changes
- @json-render/core@0.9.1
- @json-render/remotion@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/remotion@0.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
@@ -29,7 +29,7 @@
"zod": "^4.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,25 @@
# com.example.json-render-demo
## 0.0.4
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
@@ -1,4 +1,4 @@
import { config as reactConfig } from "@repo/eslint-config/react-internal";
import { config as reactConfig } from "@internal/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config[]} */
export default [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.1",
"version": "0.0.4",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -21,7 +21,7 @@
"test": "jest"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@stripe/ui-extension-tools": "^0.0.1",
"eslint": "^9.39.0"
}
@@ -1,5 +1,5 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
/**
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
/** Function to update data */
setData?: SetState;
/** Callback when data changes */
onStateChange?: (path: string, value: unknown) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
@@ -0,0 +1,25 @@
# com.example.json-render-fullpage-demo
## 0.0.4
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
@@ -1,4 +1,4 @@
import { config as reactConfig } from "@repo/eslint-config/react-internal";
import { config as reactConfig } from "@internal/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config[]} */
export default [
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.1",
"version": "0.0.4",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
@@ -21,7 +21,7 @@
"test": "jest"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@stripe/ui-extension-tools": "^0.0.1",
"eslint": "^9.39.0"
}
@@ -1,5 +1,5 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
/**
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
/** Function to update data */
setData?: SetState;
/** Callback when data changes */
onStateChange?: (path: string, value: unknown) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
+10
View File
@@ -0,0 +1,10 @@
# vite-renderers
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/vue@0.10.0
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@json-render multi-renderer demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
#renderer-root {
max-width: 640px;
margin: 0 auto;
padding: 24px;
}
</style>
</head>
<body>
<div id="renderer-root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "vite-renderers",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "portless vite-renderers.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
+30
View File
@@ -0,0 +1,30 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react";
const container = document.getElementById("renderer-root") as HTMLElement;
let unmountCurrent: (() => void) | null = null;
async function switchTo(renderer: Renderer) {
unmountCurrent?.();
container.innerHTML = "";
if (renderer === "vue") {
const mod = await import("./vue/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
// The RendererTabs component (rendered by JSON renderer) dispatches this event
document.addEventListener("switch-renderer", (e: Event) => {
switchTo((e as CustomEvent<string>).detail as Renderer);
});
// Default: Vue
switchTo("vue");
+51
View File
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import type { Spec } from "@json-render/core";
import {
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/react";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
function DemoRenderer({ spec }: { spec: Spec }) {
const { get, set } = useStateStore();
const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
export default function App({
initialRenderer = "vue",
spec,
}: {
initialRenderer?: string;
spec: Spec;
}) {
return (
<div className={`renderer-${initialRenderer}`}>
<StateProvider
initialState={{ ...spec.state, renderer: initialRenderer }}
>
<DemoRenderer spec={spec} />
</StateProvider>
</div>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/react/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,15 @@
import { createRoot, type Root } from "react-dom/client";
import type { Spec } from "@json-render/core";
import App from "./App";
let root: Root | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
root = createRoot(container);
root.render(<App initialRenderer={renderer} spec={spec} />);
}
export function unmount() {
root?.unmount();
root = null;
}
@@ -0,0 +1,155 @@
import type { Components } from "@json-render/react";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => (
<div
className={[
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
}}
>
{children}
</div>
),
Card: ({ props, children }) => (
<div className="json-render-card">
{props.title && (
<div className="json-render-card-title-wrap">
<h2 className="json-render-card-title">{props.title}</h2>
</div>
)}
{props.subtitle && (
<p className="json-render-card-subtitle">{props.subtitle}</p>
)}
{children}
</div>
),
Text: ({ props }) => (
<span
className={[
"json-render-text",
props.size && props.size !== "md" && `json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={props.color ? { color: props.color } : undefined}
>
{String(props.content ?? "")}
</span>
),
Button: ({ props, emit }) => (
<button
disabled={props.disabled}
onClick={() => emit("press")}
className={[
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{props.label}
</button>
),
Badge: ({ props }) => (
<span
className="json-render-badge"
style={
props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined
}
>
{props.label}
</span>
),
ListItem: ({ props, emit }) => (
<div
onClick={() => emit("press")}
className={[
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
className={[
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{props.completed ? "✓" : ""}
</div>
<span
className={[
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{props.title}
</span>
</div>
),
RendererBadge: ({ props }) => (
<span className="json-render-renderer-badge">
<span className="json-render-renderer-dot" />
{props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
</span>
),
RendererTabs: ({ props, emit }) => (
<div className="json-render-renderer-tabs-wrapper">
<span className="json-render-renderer-tabs-label">Render</span>
<div className="json-render-renderer-tabs">
<button
onClick={() => emit("pressVue")}
className={[
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => emit("pressReact")}
className={[
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
</div>
</div>
),
};
@@ -0,0 +1,99 @@
import { z } from "zod";
/**
* Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
* Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
*/
export const catalogDef = {
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
RendererTabs: {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue and React renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
slots: [],
description: "Badge indicating which renderer is currently active",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: { params: z.object({}), description: "Reset the counter to 0" },
toggleItem: {
params: z.object({ index: z.number() }),
description: "Toggle the completed state of a todo item",
},
switchToVue: {
params: z.object({}),
description: "Switch to the Vue renderer",
},
switchToReact: {
params: z.object({}),
description: "Switch to the React renderer",
},
},
};
@@ -0,0 +1,50 @@
type Get = (path: string) => unknown;
type Set = (path: string, value: unknown) => void;
/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
export const actionStubs = {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
export function makeHandlers(get: Get, set: Set) {
return {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) todos[index] = { ...item, completed: !item.completed };
set("/todos", todos);
},
switchToVue: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "vue" }),
);
},
switchToReact: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "react" }),
);
},
};
}
@@ -0,0 +1,248 @@
/* ---- Stack ---------------------------------------------------------------- */
.json-render-stack {
display: flex;
flex-direction: column;
align-items: stretch;
}
.json-render-stack--horizontal {
flex-direction: row;
align-items: center;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
/* ---- Card ----------------------------------------------------------------- */
.json-render-card {
background-color: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.json-render-card-title-wrap {
margin-bottom: 4px;
}
.json-render-card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.json-render-card-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px 0;
}
/* ---- Text ----------------------------------------------------------------- */
.json-render-text {
font-size: 14px;
font-weight: 400;
color: #111827;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
/* ---- Button --------------------------------------------------------------- */
.json-render-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: background 0.15s;
background-color: #3b82f6;
color: white;
}
.json-render-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.json-render-button--primary {
background-color: #3b82f6;
color: white;
}
.json-render-button--secondary {
background-color: #f3f4f6;
color: #374151;
}
.json-render-button--danger {
background-color: #fee2e2;
color: #dc2626;
}
/* ---- Badge ---------------------------------------------------------------- */
.json-render-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
/* ---- ListItem ------------------------------------------------------------- */
.json-render-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
}
.json-render-list-item--done {
background-color: #f0fdf4;
border-color: #bbf7d0;
}
.json-render-list-item-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #d1d5db;
background-color: transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
}
.json-render-list-item-check--done {
border-color: #16a34a;
background-color: #16a34a;
}
.json-render-list-item-text {
font-size: 14px;
color: #111827;
text-decoration: none;
}
.json-render-list-item-text--done {
color: #6b7280;
text-decoration: line-through;
}
/* ---- RendererBadge -------------------------------------------------------- */
.json-render-renderer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 10px 15px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
/* default colors; overridden by renderer parent class below */
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.json-render-renderer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
/* background overridden by renderer parent class below */
background-color: #0369a1;
}
/* ---- RendererTabs --------------------------------------------------------- */
.json-render-renderer-tabs-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.json-render-renderer-tabs-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.json-render-renderer-tabs {
display: inline-flex;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.json-render-renderer-tab {
padding: 6px 16px;
border: none;
border-right: 0;
cursor: pointer;
font-size: 13px;
font-weight: 500;
background-color: white;
color: #374151;
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
border-right: 1px solid #e5e7eb;
}
/* ---- Renderer-specific overrides ----------------------------------------- */
.renderer-vue .json-render-renderer-badge {
color: #42b883;
background-color: #42b88318;
border-color: #42b88340;
}
.renderer-vue .json-render-renderer-dot {
background-color: #42b883;
}
.renderer-vue .json-render-renderer-tab--active {
background-color: #42b883;
color: white;
}
.renderer-react .json-render-renderer-badge {
color: #149eca;
background-color: #149eca18;
border-color: #149eca40;
}
.renderer-react .json-render-renderer-dot {
background-color: #149eca;
}
.renderer-react .json-render-renderer-tab--active {
background-color: #149eca;
color: white;
}
+126
View File
@@ -0,0 +1,126 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
renderer: "vue",
count: 0,
todos: [
{ id: 1, title: "Learn JSON Render", completed: true },
{
id: 2,
title: "Try @json-render/vue or @json-render/react",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"demo-title",
"renderer-tabs",
"renderer-badge",
"counter-card",
"milestone-badge",
"todos-card",
],
},
"demo-title": {
type: "Text",
props: {
content: "@json-render multi-renderer demo",
size: "xl",
weight: "bold",
},
},
"renderer-badge": {
type: "RendererBadge",
props: { renderer: { $state: "/renderer" } },
},
"renderer-tabs": {
type: "RendererTabs",
props: { renderer: { $state: "/renderer" } },
on: {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
},
};
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/vue";
import DemoRenderer from "./DemoRenderer.vue";
const props = defineProps<{
initialRenderer?: string; spec: Spec }>();
const initialState = {
...props.spec.state,
renderer: props.initialRenderer ?? "vue" };
</script>
<template>
<div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
<StateProvider :initial-state="initialState">
<DemoRenderer :spec="props.spec" />
</StateProvider>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import {
ActionProvider, ValidationProvider, VisibilityProvider,
Renderer, defineRegistry, useStateStore,
} from "@json-render/vue";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const props = defineProps<{ spec: Spec }>();
const { get, set } = useStateStore();
const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
const handlers = makeHandlers(get, set);
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="props.spec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
@@ -0,0 +1,5 @@
import { schema } from "@json-render/vue/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
+15
View File
@@ -0,0 +1,15 @@
import { createApp, type App } from "vue";
import type { Spec } from "@json-render/core";
import VueApp from "./App.vue";
let app: App | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
app = createApp(VueApp, { initialRenderer: renderer, spec });
app.mount(container);
}
export function unmount() {
app?.unmount();
app = null;
}
+166
View File
@@ -0,0 +1,166 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) =>
h(
"div",
{
class: [
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" "),
style: {
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
},
},
children,
),
Card: ({ props, children }) =>
h("div", { class: "json-render-card" }, [
props.title &&
h("div", { class: "json-render-card-title-wrap" }, [
h("h2", { class: "json-render-card-title" }, props.title),
]),
props.subtitle &&
h("p", { class: "json-render-card-subtitle" }, props.subtitle),
children,
]),
Text: ({ props }) =>
h(
"span",
{
class: [
"json-render-text",
props.size &&
props.size !== "md" &&
`json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" "),
style: props.color ? { color: props.color } : undefined,
},
String(props.content ?? ""),
),
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
class: [
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" "),
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
class: "json-render-badge",
style: props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined,
},
props.label,
),
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
class: [
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" "),
},
[
h(
"div",
{
class: [
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" "),
},
props.completed ? "✓" : "",
),
h(
"span",
{
class: [
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" "),
},
props.title,
),
],
),
RendererBadge: ({ props }) =>
h("span", { class: "json-render-renderer-badge" }, [
h("span", { class: "json-render-renderer-dot" }),
props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
]),
RendererTabs: ({ props, emit }) =>
h("div", { class: "json-render-renderer-tabs-wrapper" }, [
h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
h("div", { class: "json-render-renderer-tabs" }, [
h(
"button",
{
onClick: () => emit("pressVue"),
class: [
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Vue",
),
h(
"button",
{
onClick: () => emit("pressReact"),
class: [
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"React",
),
]),
]),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [vue(), react({ include: /\.tsx$/ })],
});
+9
View File
@@ -0,0 +1,9 @@
# example-vue
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/vue@0.10.0
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>json-render vue example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f9fafb;
color: #111827;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "example-vue",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "portless vue.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"vue": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.2.5"
}
}
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { StateProvider } from "@json-render/vue";
import { demoSpec } from "./lib/spec";
import DemoRenderer from "./DemoRenderer.vue";
const initialState = demoSpec.state ?? {};
</script>
<template>
<!-- StateProvider sets up the reactive state store for the whole tree -->
<StateProvider :initial-state="initialState">
<DemoRenderer />
</StateProvider>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/vue";
import { catalog } from "./lib/catalog";
import { components } from "./lib/registry";
import { demoSpec } from "./lib/spec";
// Access the state store provided by the parent StateProvider
const { get, set } = useStateStore();
// Build registry — include stub actions to satisfy catalog types.
// The actual logic runs in the handlers below, which have direct
// access to the state store.
const { registry } = defineRegistry(catalog, {
components,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
},
});
// Action handlers — close over the state store's get/set so they
// can read and write state directly without needing an external store.
const handlers = {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) {
todos[index] = { ...item, completed: !item.completed };
}
set("/todos", todos);
},
};
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="demoSpec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
+92
View File
@@ -0,0 +1,92 @@
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
Input: {
props: z.object({
value: z.string().optional(),
placeholder: z.string().optional(),
}),
slots: [],
description: "A text input field that supports two-way state binding",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: {
params: z.object({}),
description: "Reset the counter to 0",
},
toggleItem: {
params: z.object({
index: z.number(),
}),
description: "Toggle the completed state of a todo item",
},
},
});
export type AppCatalog = typeof catalog;
+213
View File
@@ -0,0 +1,213 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import { useBoundProp } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => {
const isHorizontal = props.direction === "horizontal";
return h(
"div",
{
style: {
display: "flex",
flexDirection: isHorizontal ? "row" : "column",
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
},
},
children,
);
},
Card: ({ props, children }) =>
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "20px",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
},
},
[
props.title &&
h("div", { style: { marginBottom: "4px" } }, [
h(
"h2",
{
style: {
fontSize: "16px",
fontWeight: "600",
color: "#111827",
margin: 0,
},
},
props.title,
),
]),
props.subtitle &&
h(
"p",
{
style: {
fontSize: "13px",
color: "#6b7280",
margin: "0 0 12px 0",
},
},
props.subtitle,
),
children,
],
),
Text: ({ props }) => {
const sizeMap: Record<string, string> = {
sm: "12px",
md: "14px",
lg: "16px",
xl: "24px",
};
const weightMap: Record<string, string> = {
normal: "400",
medium: "500",
bold: "700",
};
return h(
"span",
{
style: {
fontSize: sizeMap[props.size ?? "md"] ?? "14px",
fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
color: props.color ?? "#111827",
},
},
String(props.content ?? ""),
);
},
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
style: {
padding: "8px 16px",
borderRadius: "8px",
border: "none",
cursor: props.disabled ? "not-allowed" : "pointer",
fontWeight: "500",
fontSize: "14px",
transition: "background 0.15s",
opacity: props.disabled ? "0.5" : "1",
backgroundColor:
props.variant === "danger"
? "#fee2e2"
: props.variant === "secondary"
? "#f3f4f6"
: "#3b82f6",
color:
props.variant === "danger"
? "#dc2626"
: props.variant === "secondary"
? "#374151"
: "white",
},
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
style: {
display: "inline-block",
padding: "4px 12px",
borderRadius: "999px",
fontSize: "13px",
fontWeight: "500",
backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
color: props.color ?? "#0369a1",
border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
},
},
props.label,
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return h("input", {
value: value ?? "",
placeholder: props.placeholder as string | undefined,
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
style: {
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid #d1d5db",
fontSize: "14px",
outline: "none",
width: "100%",
boxSizing: "border-box",
},
});
},
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
style: {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 12px",
borderRadius: "8px",
cursor: "pointer",
backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
},
},
[
h(
"div",
{
style: {
width: "18px",
height: "18px",
borderRadius: "50%",
border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
backgroundColor: props.completed ? "#16a34a" : "transparent",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "11px",
color: "white",
},
},
props.completed ? "✓" : "",
),
h(
"span",
{
style: {
fontSize: "14px",
color: props.completed ? "#6b7280" : "#111827",
textDecoration: props.completed ? "line-through" : "none",
},
},
props.title,
),
],
),
};
+138
View File
@@ -0,0 +1,138 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
count: 0,
name: "",
todos: [
{ id: 1, title: "Learn Vue 3", completed: true },
{ id: 2, title: "Try @json-render/vue", completed: false },
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"header",
"counter-card",
"milestone-badge",
"todos-card",
"input-card",
],
},
header: {
type: "Text",
props: {
content: "@json-render/vue demo",
size: "xl",
weight: "bold",
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
// ---- Bound Input card (useBoundProp demo) ----
"input-card": {
type: "Card",
props: {
title: "Bound Input",
subtitle: "Type to update state — the display text reacts in real time",
},
children: ["input-body"],
},
"input-body": {
type: "Stack",
props: { gap: 12, direction: "vertical" },
children: ["name-input", "name-display"],
},
"name-input": {
type: "Input",
props: {
value: { $bindState: "/name" },
placeholder: "Enter your name…",
},
},
"name-display": {
type: "Text",
props: {
content: { $state: "/name" },
size: "md",
color: "#6b7280",
},
},
},
};
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

Some files were not shown because too many files have changed in this diff Show More