Compare commits

..
Author SHA1 Message Date
Chris Tate d28481ea05 react-three-fiber 2026-03-12 10:08:03 -05:00
Chris Tate 316439fcd7 add solid example (#208) 2026-03-11 22:54:56 -05:00
Chris Tateandctate c180f529a0 feat: add strict mode to jsonSchema() for LLM structured outputs (#193)
* feat: add strict mode to jsonSchema() for LLM structured outputs

This PR adds support for generating strict JSON schemas compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).

## Problem
`catalog.jsonSchema()` was generating schemas that OpenAI's strict mode rejected due to:
- Use of `propertyNames` keyword (not permitted)
- Missing `additionalProperties: false` on nested objects
- Optional properties not being handled correctly for strict validation

## Changes
- Added `JsonSchemaOptions` interface with `strict` boolean flag
- Updated `catalog.jsonSchema()` to accept optional `JsonSchemaOptions` parameter
- When `strict: true`, the schema generator:
  - Sets `additionalProperties: false` on all object types
  - Removes `propertyNames` constraints
  - Lists all properties (including optional ones) in `required` arrays, using nullable types for optionals
  - Converts record types to fixed-key objects without additional property schemas
- Fixed Zod 4 compatibility issue in type name resolution
- Added comprehensive test suite covering all strict mode requirements
- Exported `JsonSchemaOptions` type from core package

## Usage
```typescript
// Default behavior (unchanged)
const schema = catalog.jsonSchema();

// Strict mode for LLM APIs
const strictSchema = catalog.jsonSchema({ strict: true });
```

The default behavior remains unchanged to maintain backward compatibility.

Fixes #180

* fix: document record type limitation and add anyOf nullable test

- Add clear JSDoc documenting that record/map types become opaque objects
  in strict mode (LLM strict schemas require additionalProperties: false)
- Improve inline comment on the record case in zodToJsonSchema
- Add test exercising the anyOf nullable wrapping for optional properties
  using a non-record schema so the path is directly verified

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
2026-03-11 22:17:09 -05:00
Chris Tate c3c0a36f20 better visual (#206) 2026-03-11 12:16:51 -05:00
Patrick HallandPatrick 9bb82604f0 solid: add renderer implementation (#201)
* solid: add renderer implementation

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

* fix: solid reactivity bugs in validation + state

---------

Co-authored-by: Patrick <phall@Patricks-MacBook-Pro.local>
2026-03-11 09:12:07 -05:00
105 changed files with 17704 additions and 244 deletions
+3 -1
View File
@@ -19,7 +19,9 @@
"@json-render/xstate",
"@json-render/image",
"@json-render/mcp",
"@json-render/svelte"
"@json-render/svelte",
"@json-render/solid",
"@json-render/react-three-fiber"
]
],
"linked": [],
+160 -43
View File
@@ -21,6 +21,8 @@ npm install @json-render/core @json-render/react-email @react-email/components @
npm install @json-render/core @json-render/vue
# or for Svelte
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
```
## Why json-render?
@@ -30,7 +32,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React, Vue, Svelte (web), React Native (mobile) from the same catalog
- **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -91,9 +93,7 @@ const { registry } = defineRegistry(catalog, {
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
<button onClick={() => emit("press")}>{props.label}</button>
),
},
});
@@ -113,24 +113,26 @@ function Dashboard({ spec }) {
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
| Package | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
## Renderers
@@ -159,7 +161,7 @@ const spec = {
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### Vue (UI)
@@ -199,6 +201,26 @@ const { registry } = defineRegistry(catalog, {
// <Renderer spec={spec} registry={registry} />
```
### Solid (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
});
<Renderer spec={spec} registry={registry} />;
```
### shadcn/ui (Web)
```tsx
@@ -229,7 +251,7 @@ const { registry } = defineRegistry(catalog, {
},
});
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### React Native (Mobile)
@@ -250,23 +272,40 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
{
id: "clip-1",
trackId: "main",
component: "TitleCard",
props: { title: "Hello" },
from: 0,
durationInFrames: 90,
},
],
audio: { tracks: [] }
audio: { tracks: [] },
};
<Player
@@ -276,7 +315,7 @@ const spec = {
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
/>;
```
### React PDF (Documents)
@@ -287,7 +326,11 @@ import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
doc: {
type: "Document",
props: { title: "Invoice" },
children: ["page-1"],
},
"page-1": {
type: "Page",
props: { size: "A4" },
@@ -301,8 +344,14 @@ const spec = {
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
@@ -327,7 +376,11 @@ const catalog = defineCatalog(schema, {
const spec = {
root: "html-1",
elements: {
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"html-1": {
type: "Html",
props: { lang: "en", dir: "ltr" },
children: ["head-1", "body-1"],
},
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
@@ -336,11 +389,17 @@ const spec = {
},
"container-1": {
type: "Container",
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
props: {
style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
},
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
"text-1": {
type: "Text",
props: { text: "Thanks for signing up." },
children: [],
},
},
};
@@ -376,6 +435,47 @@ import { renderToSvg } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
```
### Three.js (3D)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
const catalog = defineCatalog(schema, {
components: {
Box: threeComponentDefinitions.Box,
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
OrbitControls: threeComponentDefinitions.OrbitControls,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Box: threeComponents.Box,
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
OrbitControls: threeComponents.OrbitControls,
},
});
<ThreeCanvas
spec={spec}
registry={registry}
shadows
camera={{ position: [5, 5, 5], fov: 50 }}
style={{ width: "100%", height: "100vh" }}
/>;
```
## Features
### Streaming (SpecStream)
@@ -425,8 +525,16 @@ Any prop value can be data-driven using expressions:
{
"type": "Icon",
"props": {
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
}
}
}
```
@@ -445,7 +553,10 @@ Components can trigger actions, including the built-in `setState` action:
```json
{
"type": "Pressable",
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
"props": {
"action": "setState",
"actionParams": { "statePath": "/activeTab", "value": "home" }
},
"children": ["home-icon"]
}
```
@@ -459,9 +570,15 @@ React to state changes by triggering actions:
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
"props": {
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
}
}
```
@@ -486,7 +603,7 @@ pnpm dev
- Chat Example: run `pnpm dev` in `examples/chat`
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
## How It Works
@@ -496,7 +613,7 @@ flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
@@ -0,0 +1,416 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-three-fiber")
# @json-render/react-three-fiber
React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
## Installation
```bash
npm install @json-render/react-three-fiber @json-render/core @json-render/react @react-three/fiber @react-three/drei three zod
```
## Entry Points
<table>
<thead>
<tr>
<th>Entry Point</th>
<th>Exports</th>
<th>Use For</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/react-three-fiber</code></td>
<td><code>threeComponents</code>, <code>ThreeRenderer</code>, <code>ThreeCanvas</code>, schemas</td>
<td>React Three Fiber implementations and renderer</td>
</tr>
<tr>
<td><code>@json-render/react-three-fiber/catalog</code></td>
<td><code>threeComponentDefinitions</code></td>
<td>Catalog schemas (no R3F dependency, safe for server)</td>
</tr>
</tbody>
</table>
## Usage
```tsx
import {'{ defineCatalog }'} from "@json-render/core";
import {'{ schema, defineRegistry }'} from "@json-render/react";
import {'{'}
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
{'}'} from "@json-render/react-three-fiber";
const catalog = defineCatalog(schema, {'{'}
components: {'{'}
Box: threeComponentDefinitions.Box,
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
OrbitControls: threeComponentDefinitions.OrbitControls,
{'}'},
actions: {'{}'},
{'}'});
const {'{ registry }'} = defineRegistry(catalog, {'{'}
components: {'{'}
Box: threeComponents.Box,
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
OrbitControls: threeComponents.OrbitControls,
{'}'},
{'}'});
```
### ThreeCanvas (convenience)
```tsx
<ThreeCanvas
spec={'{spec}'}
registry={'{registry}'}
shadows
camera={'{'}{'{ position: [5, 5, 5], fov: 50 }'}{'}'}
style={'{'}{'{ width: "100%", height: "100vh" }'}{'}'}
/>
```
### Manual Canvas Setup
```tsx
import {'{ Canvas }'} from "@react-three/fiber";
import {'{ ThreeRenderer }'} from "@json-render/react-three-fiber";
<Canvas shadows>
<ThreeRenderer spec={'{spec}'} registry={'{registry}'} />
</Canvas>
```
## Components
### Primitives
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
<th>Key Props</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Box</code></td>
<td>Box mesh (default 1x1x1)</td>
<td><code>width</code>, <code>height</code>, <code>depth</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Sphere</code></td>
<td>Sphere mesh</td>
<td><code>radius</code>, <code>widthSegments</code>, <code>heightSegments</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Cylinder</code></td>
<td>Cylinder mesh</td>
<td><code>radiusTop</code>, <code>radiusBottom</code>, <code>height</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Cone</code></td>
<td>Cone mesh</td>
<td><code>radius</code>, <code>height</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Torus</code></td>
<td>Torus (donut) mesh</td>
<td><code>radius</code>, <code>tube</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Plane</code></td>
<td>Flat plane mesh</td>
<td><code>width</code>, <code>height</code>, <code>material</code></td>
</tr>
<tr>
<td><code>Capsule</code></td>
<td>Capsule mesh</td>
<td><code>radius</code>, <code>length</code>, <code>material</code></td>
</tr>
</tbody>
</table>
All primitives share: <code>position</code>, <code>rotation</code>, <code>scale</code>, <code>castShadow</code>, <code>receiveShadow</code>, <code>material</code>.
### Material Schema
<table>
<thead>
<tr>
<th>Property</th>
<th>Type</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>color</code></td>
<td><code>string</code></td>
<td><code>"#ffffff"</code></td>
</tr>
<tr>
<td><code>metalness</code></td>
<td><code>number</code></td>
<td><code>0</code></td>
</tr>
<tr>
<td><code>roughness</code></td>
<td><code>number</code></td>
<td><code>1</code></td>
</tr>
<tr>
<td><code>emissive</code></td>
<td><code>string</code></td>
<td><code>"#000000"</code></td>
</tr>
<tr>
<td><code>emissiveIntensity</code></td>
<td><code>number</code></td>
<td><code>1</code></td>
</tr>
<tr>
<td><code>opacity</code></td>
<td><code>number</code></td>
<td><code>1</code></td>
</tr>
<tr>
<td><code>transparent</code></td>
<td><code>boolean</code></td>
<td><code>false</code></td>
</tr>
<tr>
<td><code>wireframe</code></td>
<td><code>boolean</code></td>
<td><code>false</code></td>
</tr>
</tbody>
</table>
### Lights
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
<th>Key Props</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>AmbientLight</code></td>
<td>Uniform illumination</td>
<td><code>color</code>, <code>intensity</code></td>
</tr>
<tr>
<td><code>DirectionalLight</code></td>
<td>Sunlight-style</td>
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>castShadow</code></td>
</tr>
<tr>
<td><code>PointLight</code></td>
<td>Radiates from a point</td>
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>distance</code>, <code>decay</code></td>
</tr>
<tr>
<td><code>SpotLight</code></td>
<td>Cone of light</td>
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>angle</code>, <code>penumbra</code></td>
</tr>
</tbody>
</table>
### Other Components
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
<th>Key Props</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Group</code></td>
<td>Container for children</td>
<td><code>position</code>, <code>rotation</code>, <code>scale</code></td>
</tr>
<tr>
<td><code>Model</code></td>
<td>GLTF/GLB model loader</td>
<td><code>url</code>, <code>position</code>, <code>rotation</code>, <code>scale</code></td>
</tr>
<tr>
<td><code>Environment</code></td>
<td>HDRI environment map</td>
<td><code>preset</code>, <code>background</code>, <code>blur</code>, <code>intensity</code></td>
</tr>
<tr>
<td><code>Fog</code></td>
<td>Linear fog effect</td>
<td><code>color</code>, <code>near</code>, <code>far</code></td>
</tr>
<tr>
<td><code>GridHelper</code></td>
<td>Reference grid</td>
<td><code>size</code>, <code>divisions</code>, <code>color</code></td>
</tr>
<tr>
<td><code>Text3D</code></td>
<td>3D text (SDF)</td>
<td><code>text</code>, <code>fontSize</code>, <code>color</code>, <code>anchorX</code>, <code>anchorY</code></td>
</tr>
<tr>
<td><code>PerspectiveCamera</code></td>
<td>Camera</td>
<td><code>position</code>, <code>fov</code>, <code>near</code>, <code>far</code>, <code>makeDefault</code></td>
</tr>
<tr>
<td><code>OrbitControls</code></td>
<td>Camera controls</td>
<td><code>enableDamping</code>, <code>enableZoom</code>, <code>autoRotate</code></td>
</tr>
</tbody>
</table>
## Shared Schemas
Reusable Zod schemas for custom 3D components:
```tsx
import {'{ vector3Schema, materialSchema, transformProps, shadowProps }'} from "@json-render/react-three-fiber";
```
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>vector3Schema</code></td>
<td><code>z.tuple([z.number(), z.number(), z.number()])</code></td>
</tr>
<tr>
<td><code>materialSchema</code></td>
<td>Standard material props (color, metalness, roughness, etc.)</td>
</tr>
<tr>
<td><code>transformProps</code></td>
<td><code>{'{ position, rotation, scale }'}</code> schema fields</td>
</tr>
<tr>
<td><code>shadowProps</code></td>
<td><code>{'{ castShadow, receiveShadow }'}</code> schema fields</td>
</tr>
</tbody>
</table>
## ThreeRenderer
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>spec</code></td>
<td><code>Spec | null</code></td>
<td>The spec to render as a 3D scene</td>
</tr>
<tr>
<td><code>registry</code></td>
<td><code>ComponentRegistry</code></td>
<td>Component registry from <code>defineRegistry</code></td>
</tr>
<tr>
<td><code>store</code></td>
<td><code>StateStore</code></td>
<td>External state store (controlled mode)</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td><code>Record&lt;string, unknown&gt;</code></td>
<td>Initial state (uncontrolled mode)</td>
</tr>
<tr>
<td><code>handlers</code></td>
<td><code>Record&lt;string, Function&gt;</code></td>
<td>Action handlers</td>
</tr>
<tr>
<td><code>loading</code></td>
<td><code>boolean</code></td>
<td>Whether the spec is streaming</td>
</tr>
<tr>
<td><code>children</code></td>
<td><code>ReactNode</code></td>
<td>Additional R3F elements alongside the spec</td>
</tr>
</tbody>
</table>
## ThreeCanvas
Extends <code>ThreeRendererProps</code> with Canvas options:
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>shadows</code></td>
<td><code>boolean</code></td>
<td>Enable shadow maps</td>
</tr>
<tr>
<td><code>camera</code></td>
<td><code>object</code></td>
<td>Default camera config (position, fov, etc.)</td>
</tr>
<tr>
<td><code>className</code></td>
<td><code>string</code></td>
<td>CSS class for the canvas container</td>
</tr>
<tr>
<td><code>style</code></td>
<td><code>CSSProperties</code></td>
<td>Inline styles for the canvas container</td>
</tr>
</tbody>
</table>
## Type Helpers
```tsx
import type {'{ ThreeProps }'} from "@json-render/react-three-fiber";
type BoxProps = ThreeProps<"Box">;
type SphereProps = ThreeProps<"Sphere">;
```
+201
View File
@@ -0,0 +1,201 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/api/solid");
# @json-render/solid
SolidJS components, providers, and hooks for rendering json-render specs.
## Installation
<PackageInstall packages="@json-render/core @json-render/solid" />
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
<PackageInstall packages="solid-js zod" />
## Providers
### StateProvider
```tsx
<StateProvider
initialState={{}}
onStateChange={(changes) => console.log(changes)}
>
{/* children */}
</StateProvider>
```
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>store</code>
</td>
<td>
<code>StateStore</code>
</td>
<td>
External store (controlled mode). When provided,{" "}
<code>initialState</code> and <code>onStateChange</code> are ignored.
</td>
</tr>
<tr>
<td>
<code>initialState</code>
</td>
<td>
<code>Record&lt;string, unknown&gt;</code>
</td>
<td>Initial state model for uncontrolled mode.</td>
</tr>
<tr>
<td>
<code>onStateChange</code>
</td>
<td>
<code>
{"(changes: Array<{ path: string; value: unknown }>) => void"}
</code>
</td>
<td>Called for uncontrolled state updates.</td>
</tr>
</tbody>
</table>
### ActionProvider
```tsx
<ActionProvider
handlers={{ submit: async (params) => {} }}
navigate={(path) => {}}
>
{/* children */}
</ActionProvider>
```
### VisibilityProvider
```tsx
<VisibilityProvider>{/* children */}</VisibilityProvider>
```
### ValidationProvider
```tsx
<ValidationProvider customFunctions={{ custom: (value) => Boolean(value) }}>
{/* children */}
</ValidationProvider>
```
### JSONUIProvider
Combined provider wrapper for state, visibility, validation, and actions.
```tsx
<JSONUIProvider
registry={registry}
initialState={{}}
handlers={handlers}
validationFunctions={validationFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
## defineRegistry
Create a typed component registry and action helpers from a catalog.
```tsx
const { registry, handlers, executeAction } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
actions: {
submit: async (params, setState, state) => {
// custom action logic
},
},
});
```
## Components
### Renderer
```tsx
<Renderer spec={spec} registry={registry} loading={false} />
```
Renders a `Spec` tree using your registry.
### createRenderer
Build an app-level renderer from catalog + components:
```tsx
const AppRenderer = createRenderer(catalog, {
Card: (renderProps) => <div>{renderProps.children}</div>,
});
<AppRenderer spec={spec} state={{}} onAction={(name, params) => {}} />;
```
## Hooks
- `useStateStore()`
- `useStateValue(path)` - returns an accessor
- `useStateBinding(path)` - returns `[Accessor<T | undefined>, setValue]`
- `useVisibility()` / `useIsVisible(condition)`
- `useActions()` / `useAction(binding)`
- `useValidation()` / `useOptionalValidation()`
- `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid`
- `useBoundProp(value, bindingPath)`
- `useUIStream(options)`
- `useChatUI(options)`
## Built-in Actions
`ActionProvider` handles these built-in actions:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
## Component Props
Registry components receive:
```ts
interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: JSX.Element;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}
```
Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`).
## Reactivity Notes
- Keep changing reads in JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when you need live updates.
- `StateProvider` and other contexts expose getter-backed values so consumers read live signals.
- `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions.
+85 -14
View File
@@ -1,5 +1,5 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/renderers")
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/renderers");
# Renderers
@@ -22,37 +22,65 @@ All renderers share the same workflow:
<tbody>
<tr>
<td>React</td>
<td><code>@json-render/react</code></td>
<td>
<code>@json-render/react</code>
</td>
<td>React component tree</td>
</tr>
<tr>
<td>Vue</td>
<td><code>@json-render/vue</code></td>
<td>
<code>@json-render/vue</code>
</td>
<td>Vue 3 component tree</td>
</tr>
<tr>
<td>Svelte</td>
<td>
<code>@json-render/svelte</code>
</td>
<td>Svelte 5 component tree</td>
</tr>
<tr>
<td>Solid</td>
<td>
<code>@json-render/solid</code>
</td>
<td>SolidJS component tree</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td><code>@json-render/shadcn</code></td>
<td>
<code>@json-render/shadcn</code>
</td>
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
</tr>
<tr>
<td>React Native</td>
<td><code>@json-render/react-native</code></td>
<td>
<code>@json-render/react-native</code>
</td>
<td>Native mobile views</td>
</tr>
<tr>
<td>Image</td>
<td><code>@json-render/image</code></td>
<td>
<code>@json-render/image</code>
</td>
<td>SVG / PNG (via Satori)</td>
</tr>
<tr>
<td>React PDF</td>
<td><code>@json-render/react-pdf</code></td>
<td>
<code>@json-render/react-pdf</code>
</td>
<td>PDF documents</td>
</tr>
<tr>
<td>Remotion</td>
<td><code>@json-render/remotion</code></td>
<td>
<code>@json-render/remotion</code>
</td>
<td>Video compositions</td>
</tr>
</tbody>
@@ -67,7 +95,7 @@ import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
@@ -90,6 +118,42 @@ const { registry } = defineRegistry(catalog, {
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
## Svelte
Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support.
```typescript
import { defineRegistry, Renderer } from "@json-render/svelte";
import { schema } from "@json-render/svelte/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => /* Svelte snippet */,
},
});
```
See the [@json-render/svelte API reference](/docs/api/svelte) for details.
## Solid
SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions.
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
},
});
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/solid API reference](/docs/api/solid) for details.
## shadcn/ui
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
@@ -125,7 +189,10 @@ Render specs as native mobile views. Includes 25+ standard components and standa
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
const catalog = defineCatalog(schema, {
@@ -134,7 +201,7 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
@@ -159,7 +226,11 @@ See the [@json-render/image API reference](/docs/api/image) for details.
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
import {
renderToBuffer,
renderToStream,
renderToFile,
} from "@json-render/react-pdf";
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
@@ -185,7 +256,7 @@ import { Renderer } from "@json-render/remotion";
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
/>;
```
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
+8 -2
View File
@@ -1,6 +1,6 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/skills")
export const metadata = pageMetadata("docs/skills");
# Skills
@@ -18,6 +18,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
- **remotion** — Remotion renderer for video generation from JSON timeline specs.
- **vue** — Vue 3 renderer for Vue component trees.
- **svelte** — Svelte 5 renderer for Svelte component trees.
- **solid** — SolidJS renderer for fine-grained reactive component trees.
- **codegen** — Code generation utilities for building custom exporters.
- **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code.
- **redux** — Redux adapter for json-render's `StateStore` interface.
@@ -38,6 +39,7 @@ npx skills add vercel-labs/json-render --skill image
npx skills add vercel-labs/json-render --skill remotion
npx skills add vercel-labs/json-render --skill vue
npx skills add vercel-labs/json-render --skill svelte
npx skills add vercel-labs/json-render --skill solid
npx skills add vercel-labs/json-render --skill codegen
npx skills add vercel-labs/json-render --skill mcp
npx skills add vercel-labs/json-render --skill redux
@@ -88,6 +90,10 @@ Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue
Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration.
## solid
Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming.
## codegen
Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+218 -61
View File
@@ -1,20 +1,24 @@
"use client";
function Skeleton({ className = "" }: { className?: string }) {
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
}
/** Simple form wireframe reused in both diagrams */
function FormUI() {
function ScheduleItem({
time,
label,
color,
}: {
time: string;
label: string;
color: string;
}) {
return (
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
{/* Title */}
<Skeleton className="h-2.5 w-14 mb-1" />
{/* Input fields */}
<Skeleton className="h-4 w-full rounded-sm" />
<Skeleton className="h-4 w-full rounded-sm" />
{/* Submit button */}
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
<div className="flex items-center gap-2 py-1.5 border-t border-border/50 first:border-t-0">
<span className="text-[10px] text-muted-foreground/60 w-12 shrink-0 tabular-nums">
{time}
</span>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: color }}
/>
<span className="text-[11px] text-muted-foreground">{label}</span>
</div>
);
}
@@ -22,48 +26,164 @@ function FormUI() {
function InlineModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
<div className="text-sm font-medium text-foreground text-center mb-3">
Inline Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
{/* Chat area */}
<div className="flex-1 p-3 overflow-hidden flex justify-center">
<div className="w-1/3 min-w-[120px] space-y-3">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted rounded-xl px-3 py-2">
<Skeleton className="h-2 w-14" />
</div>
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-col">
<div className="flex-1 p-4 space-y-3 overflow-hidden">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-3.5 py-2 max-w-[85%]">
<span className="text-[11px] text-foreground/80">
I have back-to-back meetings today
</span>
</div>
</div>
{/* Assistant text */}
<div className="space-y-2">
<div className="space-y-1.5">
<Skeleton className="h-2 w-full" />
<Skeleton className="h-2 w-3/4" />
</div>
{/* Inline UI */}
<FormUI />
{/* More text after UI */}
<div className="space-y-1.5">
<Skeleton className="h-2 w-4/5" />
{/* AI response */}
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground/70">
Packed day! Here&apos;s what you&apos;ve got:
</p>
<div className="bg-muted/40 border border-border rounded-xl p-3 w-[92%]">
<div className="text-[11px] font-semibold text-foreground/80 mb-2">
Today&apos;s Schedule
</div>
<ScheduleItem
time="10:00 AM"
label="Design Review"
color="#7aa2f7"
/>
<ScheduleItem
time="1:00 PM"
label="Sprint Planning"
color="#bb9af7"
/>
<ScheduleItem
time="3:30 PM"
label="Team Standup"
color="#9ece6a"
/>
<ScheduleItem
time="4:30 PM"
label="Eng All-Hands"
color="#e0af68"
/>
</div>
</div>
</div>
{/* Input bar */}
<div className="p-2 flex justify-center">
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
<Skeleton className="h-7 flex-1 rounded-md" />
<Skeleton className="h-7 w-7 rounded-md" />
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Text + UI interleaved in messages
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
AI decides when UI beats text
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>AI chatbots</span>
<span className="text-muted-foreground/30">/</span>
<span>Copilots</span>
<span className="text-muted-foreground/30">/</span>
<span>Assistants</span>
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<div className="w-full h-full flex flex-col bg-muted/20">
{/* Nav */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
className="shrink-0"
>
<rect
x="2"
y="14"
width="5"
height="8"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="9.5"
y="8"
width="5"
height="14"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="17"
y="2"
width="5"
height="20"
rx="1"
className="fill-muted-foreground/60"
/>
</svg>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground/50">Pricing</span>
<span className="text-[9px] text-muted-foreground/50">Blog</span>
<span className="text-[8px] font-semibold bg-foreground text-background rounded-md px-1.5 py-0.5 whitespace-nowrap">
Get Started
</span>
</div>
</div>
{/* Hero */}
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 text-center px-4">
<span className="text-[8px] font-medium text-green-400 bg-green-400/10 border border-green-400/15 rounded-full px-2 py-0.5">
Trusted by 2,000+ teams
</span>
<div className="text-sm font-bold text-foreground leading-tight">
Analytics that
<br />
move the needle
</div>
<div className="text-[10px] text-muted-foreground/50 leading-relaxed">
Ship what matters. No setup required.
</div>
<div className="flex gap-1.5 mt-1">
<button className="bg-foreground text-background text-[9px] font-semibold rounded-lg px-3 py-1.5 whitespace-nowrap">
Start Free
</button>
<button className="border border-border text-muted-foreground text-[9px] font-medium rounded-lg px-3 py-1.5 whitespace-nowrap">
Book Demo
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-center gap-3 py-2 border-t border-border/50">
<span className="text-[8px] text-muted-foreground/30">Privacy</span>
<span className="text-[8px] text-muted-foreground/30">Terms</span>
<span className="text-[8px] text-muted-foreground/30">Status</span>
</div>
</div>
);
@@ -72,28 +192,65 @@ function InlineModeDiagram() {
function StandaloneModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
<div className="text-sm font-medium text-foreground text-center mb-3">
Standalone Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
{/* Left panel - prompt */}
<div className="w-[38%] border-r border-border flex flex-col">
<div className="flex-1" />
<div className="p-3 space-y-2">
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-5 w-16 rounded-md" />
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-row">
{/* Left panel - conversation */}
<div className="w-[38%] border-r border-border/50 flex flex-col">
<div className="flex-1 p-3 space-y-2.5">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-2.5 py-1.5">
<span className="text-[10px] text-foreground/80 block leading-relaxed">
A landing page for my analytics startup
</span>
</div>
</div>
{/* AI text */}
<p className="text-[10px] text-muted-foreground/60 leading-relaxed">
Here&apos;s a clean landing page with a hero, nav, and CTAs.
</p>
</div>
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
{/* Right panel - UI preview */}
<div className="flex-1 p-3 flex items-center justify-center">
<div className="w-3/4">
<FormUI />
</div>
{/* Right panel - landing page */}
<div className="flex-1">
<LandingPage />
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Prompt separate from UI preview
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
Prompt in, UI out
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>Website builders</span>
<span className="text-muted-foreground/30">/</span>
<span>Text-to-widget</span>
<span className="text-muted-foreground/30">/</span>
<span>Dashboards</span>
</div>
</div>
</div>
);
@@ -103,10 +260,10 @@ export function GenerationModesDiagram() {
return (
<div className="not-prose my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="h-[280px]">
<div className="h-[420px]">
<InlineModeDiagram />
</div>
<div className="h-[280px]">
<div className="h-[420px]">
<StandaloneModeDiagram />
</div>
</div>
+6 -1
View File
@@ -92,7 +92,7 @@ export const docsNavigation: NavSection[] = [
external: true,
},
{
title: "Renders with Vite (Vue / React / Svelte)",
title: "Renders with Vite (Vue / React / Svelte / Solid)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
@@ -133,6 +133,11 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
{ title: "@json-render/solid", href: "/docs/api/solid" },
{
title: "@json-render/react-three-fiber",
href: "/docs/api/react-three-fiber",
},
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
{ title: "@json-render/redux", href: "/docs/api/redux" },
+2
View File
@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
@@ -55,6 +56,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/redux": "@json-render/redux API",
"docs/api/zustand": "@json-render/zustand API",
"docs/api/jotai": "@json-render/jotai API",
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
"docs/api/xstate": "@json-render/xstate API",
};
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "json-render React Three Fiber Example",
description: "3D scenes from JSON specs with @json-render/react-three-fiber",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
{children}
</body>
</html>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { defineCatalog } from "@json-render/core";
import type { ComputedFunction } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
import { scenes } from "./scenes";
const catalog = defineCatalog(schema, {
components: {
...threeComponentDefinitions,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
...threeComponents,
},
});
function num(v: unknown, fallback: number): number {
return typeof v === "number" ? v : fallback;
}
const computedFunctions: Record<string, ComputedFunction> = {
halfHeight: (a) => num(a.h, 1) / 2,
helixX: (a) => {
const angle =
(num(a.i, 0) / num(a.count, 40)) * num(a.turns, 3) * 2 * Math.PI +
num(a.strand, 0) * Math.PI;
return num(a.radius, 1.8) * Math.cos(angle);
},
helixY: (a) => num(a.i, 0) * num(a.spacing, 0.3) + num(a.offset, -6),
helixZ: (a) => {
const angle =
(num(a.i, 0) / num(a.count, 40)) * num(a.turns, 3) * 2 * Math.PI +
num(a.strand, 0) * Math.PI;
return num(a.radius, 1.8) * Math.sin(angle);
},
helixHue: (a) => {
const t = num(a.i, 0) / num(a.count, 40);
return `hsl(${Math.round(t * 270 + 180)}, 85%, 60%)`;
},
spiralX: (a) => {
const t = num(a.i, 0) / num(a.count, 60);
const r = 0.5 + t * num(a.maxRadius, 7);
const angle = t * num(a.turns, 4) * 2 * Math.PI;
return r * Math.cos(angle);
},
spiralZ: (a) => {
const t = num(a.i, 0) / num(a.count, 60);
const r = 0.5 + t * num(a.maxRadius, 7);
const angle = t * num(a.turns, 4) * 2 * Math.PI;
return r * Math.sin(angle);
},
spiralY: (a) => {
const i = num(a.i, 0);
return Math.sin(i * 1.7) * 0.4;
},
spiralScale: (a) => {
const t = num(a.i, 0) / num(a.count, 60);
return 0.08 + (1 - t) * 0.25;
},
spiralEmissive: (a) => {
const t = num(a.i, 0) / num(a.count, 60);
return Math.max(0, 1 - t * 1.5);
},
circleX: (a) => {
const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
return num(a.radius, 3) * Math.cos(angle);
},
circleZ: (a) => {
const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
return num(a.radius, 3) * Math.sin(angle);
},
circleY: (a) => {
const angle = (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
return Math.sin(angle * num(a.freq, 3)) * num(a.amp, 0.3);
},
circleAngle: (a) => {
return (num(a.i, 0) / num(a.count, 8)) * 2 * Math.PI;
},
circleHue: (a) => {
const t = num(a.i, 0) / num(a.count, 8);
return `hsl(${Math.round(t * 360)}, ${num(a.sat, 70)}%, ${num(a.lit, 80)}%)`;
},
};
function highlightJson(json: string): string {
return json.replace(
/("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
(_, key, colon, str, num, lit, punct) => {
if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
if (str) return `<span style="color:#00CA50">${str}</span>`;
if (num) return `<span style="color:#47A8FF">${num}</span>`;
if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
if (punct) return `<span style="color:#666">${punct}</span>`;
return _;
},
);
}
const LIST_WIDTH = 220;
const JSON_WIDTH = 380;
const HEADER_HEIGHT = 40;
const headerStyle: React.CSSProperties = {
height: HEADER_HEIGHT,
display: "flex",
alignItems: "center",
padding: "0 16px",
fontSize: 11,
fontWeight: 600,
color: "#555",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontFamily: "ui-monospace, monospace",
borderBottom: "1px solid #1e1e1e",
flexShrink: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
boxSizing: "border-box",
};
export default function Page() {
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = scenes[selectedIndex]!;
return (
<div style={{ height: "100vh", display: "flex", background: "#0a0a0a" }}>
<div
style={{
width: LIST_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: "1px solid #1e1e1e",
background: "#0f0f0f",
}}
>
<div style={headerStyle}>Scenes</div>
<div style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}>
{scenes.map((scene, i) => (
<button
key={scene.name}
onClick={() => setSelectedIndex(i)}
style={{
display: "block",
width: "100%",
padding: "8px 16px",
fontSize: 13,
border: "none",
textAlign: "left",
background:
i === selectedIndex
? "rgba(255,255,255,0.08)"
: "transparent",
color: i === selectedIndex ? "#fff" : "#888",
fontWeight: i === selectedIndex ? 500 : 400,
cursor: "pointer",
borderLeft:
i === selectedIndex
? "2px solid #fff"
: "2px solid transparent",
fontFamily: "inherit",
}}
>
{scene.name}
</button>
))}
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minWidth: 0,
}}
>
<div style={headerStyle}>{selected.description}</div>
<div style={{ flex: 1, background: "#000", position: "relative" }}>
<ThreeCanvas
key={selectedIndex}
spec={selected.spec}
registry={registry}
functions={computedFunctions}
shadows
camera={{ position: [0, 0, 5], fov: 50 }}
style={{ width: "100%", height: "100%" }}
/>
</div>
</div>
<div
style={{
width: JSON_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid #1e1e1e",
background: "#0d0d0d",
}}
>
<div style={headerStyle}>Spec JSON</div>
<pre
style={{
flex: 1,
margin: 0,
padding: 14,
overflowY: "auto",
overflowX: "auto",
fontSize: 11,
lineHeight: 1.6,
fontFamily:
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
color: "#EDEDED",
tabSize: 2,
}}
dangerouslySetInnerHTML={{
__html: highlightJson(JSON.stringify(selected.spec, null, 2)),
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import type { Spec } from "@json-render/core";
export type Scene = { name: string; description: string; spec: Spec };
export const PI = Math.PI;
export function range(n: number) {
return Array.from({ length: n }, (_, i) => ({ i }));
}
@@ -0,0 +1,474 @@
import type { Scene } from "./_helpers";
export const clockworkOrrery: Scene = {
name: "Clockwork Orrery",
description:
"Interlocking brass/gold rings spinning at different tilts and speeds with orbiting metallic planets. Like the inside of a celestial clockwork mechanism.",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"env",
"ambient",
"key",
"warm",
"accent",
"center",
"ring-spin-0",
"ring-spin-1",
"ring-spin-2",
"ring-spin-3",
"planet-orbit-0",
"planet-orbit-1",
"planet-orbit-2",
"sparkles",
"post",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 3, 8],
fov: 50,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "dawn",
background: true,
blur: 1,
intensity: 0.5,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#332200", intensity: 0.4 },
children: [],
},
key: {
type: "DirectionalLight",
props: {
position: [5, 10, 5],
rotation: null,
scale: null,
color: "#ffddaa",
intensity: 2,
castShadow: true,
},
children: [],
},
warm: {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#ffaa44",
intensity: 30,
distance: 15,
decay: 2,
castShadow: false,
},
children: [],
},
accent: {
type: "PointLight",
props: {
position: [-4, 3, -4],
rotation: null,
scale: null,
color: "#ffcc88",
intensity: 15,
distance: 15,
decay: 2,
castShadow: false,
},
children: [],
},
center: {
type: "Float",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.5,
rotationIntensity: 0.1,
floatIntensity: 0.2,
enabled: true,
},
children: ["center-sphere"],
},
"center-sphere": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.5,
widthSegments: 32,
heightSegments: 16,
color: "#ffddaa",
transmission: 1,
thickness: 0.8,
roughness: 0,
chromaticAberration: 0.1,
ior: 1.8,
distortion: 0.05,
distortionScale: null,
temporalDistortion: null,
samples: 10,
resolution: 256,
},
children: [],
},
"ring-spin-0": {
type: "Spin",
props: {
position: null,
rotation: [0.3, 0, 0.8],
scale: null,
speed: 1.2,
axis: "y",
},
children: ["ring-torus-0"],
},
"ring-torus-0": {
type: "Torus",
props: {
position: null,
rotation: [1.5708, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ffcc44",
metalness: 0.95,
roughness: 0.1,
emissive: "#cc9900",
emissiveIntensity: 0.4,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 2,
tube: 0.04,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"ring-spin-1": {
type: "Spin",
props: {
position: null,
rotation: [0.9, 0, -0.2],
scale: null,
speed: -0.7,
axis: "y",
},
children: ["ring-torus-1"],
},
"ring-torus-1": {
type: "Torus",
props: {
position: null,
rotation: [1.5708, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#cc8833",
metalness: 0.95,
roughness: 0.1,
emissive: "#aa6622",
emissiveIntensity: 0.3,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 3.2,
tube: 0.035,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"ring-spin-2": {
type: "Spin",
props: {
position: null,
rotation: [-0.4, 0, 0.5],
scale: null,
speed: 0.4,
axis: "y",
},
children: ["ring-torus-2"],
},
"ring-torus-2": {
type: "Torus",
props: {
position: null,
rotation: [1.5708, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#bb9944",
metalness: 0.95,
roughness: 0.1,
emissive: "#886622",
emissiveIntensity: 0.25,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 4.5,
tube: 0.03,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"ring-spin-3": {
type: "Spin",
props: {
position: null,
rotation: [0.1, 0, -0.1],
scale: null,
speed: -0.2,
axis: "y",
},
children: ["ring-torus-3"],
},
"ring-torus-3": {
type: "Torus",
props: {
position: null,
rotation: [1.5708, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#997744",
metalness: 0.95,
roughness: 0.1,
emissive: "#664422",
emissiveIntensity: 0.15,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 6,
tube: 0.025,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"planet-orbit-0": {
type: "Orbit",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: 0.6,
radius: 2.5,
tilt: 0.3,
},
children: ["planet-spin-0"],
},
"planet-spin-0": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 3,
axis: "y",
},
children: ["planet-sphere-0"],
},
"planet-sphere-0": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ffffff",
metalness: 1,
roughness: 0.02,
emissive: "#ffffff",
emissiveIntensity: 0.2,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.2,
widthSegments: 32,
heightSegments: 16,
},
children: [],
},
"planet-orbit-1": {
type: "Orbit",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: -0.9,
radius: 3.8,
tilt: 0.8,
},
children: ["planet-spin-1"],
},
"planet-spin-1": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -2,
axis: "x",
},
children: ["planet-sphere-1"],
},
"planet-sphere-1": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ffcc88",
metalness: 0.8,
roughness: 0.15,
emissive: "#ffcc88",
emissiveIntensity: 0.2,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.15,
widthSegments: 32,
heightSegments: 16,
},
children: [],
},
"planet-orbit-2": {
type: "Orbit",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: 0.35,
radius: 5.2,
tilt: -0.5,
},
children: ["planet-spin-2"],
},
"planet-spin-2": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 1.5,
axis: "z",
},
children: ["planet-sphere-2"],
},
"planet-sphere-2": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ccaa66",
metalness: 0.9,
roughness: 0.05,
emissive: "#ccaa66",
emissiveIntensity: 0.2,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.18,
widthSegments: 32,
heightSegments: 16,
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: null,
rotation: null,
scale: [14, 10, 14],
count: 150,
speed: 0.15,
opacity: 0.3,
color: "#ffcc66",
size: 1,
noise: 2,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 1.2,
luminanceThreshold: 0.2,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.5, darkness: 0.5 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 4,
maxDistance: 18,
minPolarAngle: 0.3,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.3,
},
children: [],
},
},
},
};
@@ -0,0 +1,692 @@
import type { Scene } from "./_helpers";
export const deepSeaAbyss: Scene = {
name: "Deep Sea Abyss",
description:
"Bioluminescent deep ocean. Pitch black with glowing jellyfish-like creatures.",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"env",
"fog",
"ambient",
"jelly-1-pulse",
"jelly-2-pulse",
"jelly-3-pulse",
"light-1",
"light-2",
"light-3",
"tentacles-1",
"tentacles-2",
"tentacles-3",
"sparkles-1",
"sparkles-2",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 1, 6],
fov: 55,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "night",
background: false,
blur: 1,
intensity: 0.05,
},
children: [],
},
fog: {
type: "Fog",
props: { color: "#000510", near: 2, far: 18 },
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#001133", intensity: 0.15 },
children: [],
},
"jelly-1-pulse": {
type: "Pulse",
props: {
position: [0, 1, 0],
rotation: null,
scale: null,
speed: 0.6,
min: 0.85,
max: 1.15,
},
children: ["jelly-1-spin"],
},
"jelly-1-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.2,
axis: "y",
},
children: ["jelly-1-body"],
},
"jelly-1-body": {
type: "DistortSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 1,
widthSegments: 64,
heightSegments: 32,
color: "#00ccff",
speed: 2,
distort: 0.5,
metalness: 0,
roughness: 0.2,
},
children: [],
},
"jelly-2-pulse": {
type: "Pulse",
props: {
position: [-4, -1, -3],
rotation: null,
scale: null,
speed: 0.8,
min: 0.8,
max: 1.2,
},
children: ["jelly-2-spin"],
},
"jelly-2-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.3,
axis: "y",
},
children: ["jelly-2-body"],
},
"jelly-2-body": {
type: "DistortSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.7,
widthSegments: 64,
heightSegments: 32,
color: "#cc44ff",
speed: 2.5,
distort: 0.6,
metalness: 0,
roughness: 0.2,
},
children: [],
},
"jelly-3-pulse": {
type: "Pulse",
props: {
position: [3, 0.5, -5],
rotation: null,
scale: null,
speed: 0.5,
min: 0.9,
max: 1.1,
},
children: ["jelly-3-spin"],
},
"jelly-3-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.15,
axis: "y",
},
children: ["jelly-3-body"],
},
"jelly-3-body": {
type: "DistortSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.5,
widthSegments: 64,
heightSegments: 32,
color: "#ff44aa",
speed: 1.5,
distort: 0.4,
metalness: 0,
roughness: 0.3,
},
children: [],
},
"light-1": {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#00ccff",
intensity: 20,
distance: 12,
decay: 2,
castShadow: null,
},
children: [],
},
"light-2": {
type: "PointLight",
props: {
position: [-4, -1, -3],
rotation: null,
scale: null,
color: "#cc44ff",
intensity: 15,
distance: 10,
decay: 2,
castShadow: null,
},
children: [],
},
"light-3": {
type: "PointLight",
props: {
position: [3, 0.5, -5],
rotation: null,
scale: null,
color: "#ff44aa",
intensity: 10,
distance: 8,
decay: 2,
castShadow: null,
},
children: [],
},
"tentacles-1": {
type: "Float",
props: {
position: [0, -1, 0],
rotation: null,
scale: null,
speed: 0.3,
rotationIntensity: 0.5,
floatIntensity: 0.8,
enabled: true,
},
children: ["t1-s1", "t1-s2", "t1-s3", "t1-s4", "t1-s5", "t1-s6"],
},
"t1-s1": {
type: "GlassSphere",
props: {
position: [0.3, -0.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.12,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t1-s2": {
type: "GlassSphere",
props: {
position: [-0.2, -1.2, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.1,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t1-s3": {
type: "GlassSphere",
props: {
position: [0.4, -1.9, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.08,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t1-s4": {
type: "GlassSphere",
props: {
position: [-0.3, -2.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.07,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t1-s5": {
type: "GlassSphere",
props: {
position: [0.1, -3.0, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.06,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t1-s6": {
type: "GlassSphere",
props: {
position: [-0.1, -3.4, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.05,
widthSegments: 16,
heightSegments: 8,
color: "#00ccff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"tentacles-2": {
type: "Float",
props: {
position: [-4, -2.5, -3],
rotation: null,
scale: null,
speed: 0.4,
rotationIntensity: 0.5,
floatIntensity: 0.8,
enabled: true,
},
children: ["t2-s1", "t2-s2", "t2-s3", "t2-s4", "t2-s5"],
},
"t2-s1": {
type: "GlassSphere",
props: {
position: [0.3, -0.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.1,
widthSegments: 16,
heightSegments: 8,
color: "#cc44ff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t2-s2": {
type: "GlassSphere",
props: {
position: [-0.2, -1.1, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.08,
widthSegments: 16,
heightSegments: 8,
color: "#cc44ff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t2-s3": {
type: "GlassSphere",
props: {
position: [0.4, -1.7, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.06,
widthSegments: 16,
heightSegments: 8,
color: "#cc44ff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t2-s4": {
type: "GlassSphere",
props: {
position: [-0.3, -2.2, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.05,
widthSegments: 16,
heightSegments: 8,
color: "#cc44ff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t2-s5": {
type: "GlassSphere",
props: {
position: [0.1, -2.6, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.05,
widthSegments: 16,
heightSegments: 8,
color: "#cc44ff",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"tentacles-3": {
type: "Float",
props: {
position: [3, -1, -5],
rotation: null,
scale: null,
speed: 0.35,
rotationIntensity: 0.5,
floatIntensity: 0.8,
enabled: true,
},
children: ["t3-s1", "t3-s2", "t3-s3", "t3-s4"],
},
"t3-s1": {
type: "GlassSphere",
props: {
position: [0.3, -0.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.09,
widthSegments: 16,
heightSegments: 8,
color: "#ff44aa",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t3-s2": {
type: "GlassSphere",
props: {
position: [-0.2, -1.0, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.07,
widthSegments: 16,
heightSegments: 8,
color: "#ff44aa",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t3-s3": {
type: "GlassSphere",
props: {
position: [0.4, -1.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.06,
widthSegments: 16,
heightSegments: 8,
color: "#ff44aa",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"t3-s4": {
type: "GlassSphere",
props: {
position: [-0.1, -1.9, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.05,
widthSegments: 16,
heightSegments: 8,
color: "#ff44aa",
transmission: 0.9,
thickness: 0.3,
roughness: 0.1,
chromaticAberration: 0.1,
ior: 1.5,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"sparkles-1": {
type: "Sparkles",
props: {
position: [0, 0, -3],
rotation: null,
scale: [15, 10, 15],
count: 300,
speed: 0.1,
opacity: 0.4,
color: "#00aaff",
size: 1,
noise: 3,
},
children: [],
},
"sparkles-2": {
type: "Sparkles",
props: {
position: [0, -2, 0],
rotation: null,
scale: [12, 6, 12],
count: 150,
speed: 0.05,
opacity: 0.2,
color: "#8844ff",
size: 0.8,
noise: 2,
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 3,
maxDistance: 18,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: true,
autoRotateSpeed: 0.15,
},
children: [],
},
},
},
};
@@ -0,0 +1,940 @@
import { PI } from "./_helpers";
import type { Scene } from "./_helpers";
export const floatingIslands: Scene = {
name: "Floating Islands",
description:
"Sky, clouds, islands with trees and bridges floating in sunset light",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"sky",
"ambient",
"sun",
"clouds",
"island-main-float",
"island-left-float",
"island-right-float",
"island-far-float",
"island-tiny1-float",
"island-tiny2-float",
"bridge-1",
"bridge-2",
"sparkles",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [8, 6, 14],
rotation: null,
scale: null,
fov: 50,
near: 0.1,
far: 500,
makeDefault: true,
},
children: [],
},
sky: {
type: "Environment",
props: {
preset: "dawn",
background: true,
blur: 0.8,
intensity: 0.5,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#ffd4a0", intensity: 0.5 },
children: [],
},
sun: {
type: "DirectionalLight",
props: {
position: [8, 12, 6],
rotation: null,
scale: null,
color: "#ffcc88",
intensity: 2.5,
castShadow: true,
},
children: [],
},
clouds: {
type: "Cloud",
props: {
position: [0, 4, -8],
rotation: null,
scale: null,
seed: 7,
segments: 40,
bounds: [25, 3, 20],
volume: 10,
speed: 0.1,
fade: 25,
opacity: 0.6,
color: "#ffe8d0",
growth: 8,
},
children: [],
},
"island-main-float": {
type: "Float",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: 0.4,
rotationIntensity: 0.02,
floatIntensity: 0.3,
enabled: true,
},
children: ["island-main"],
},
"island-main": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"main-base",
"main-top",
"main-underhang",
"tree-1-trunk",
"tree-1-canopy",
"tree-2-trunk",
"tree-2-canopy",
"tree-3-trunk",
"tree-3-canopy",
"house-base",
"house-roof",
],
},
"main-base": {
type: "Cylinder",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 3,
radiusBottom: 1.5,
height: 2,
radialSegments: 16,
},
children: [],
},
"main-top": {
type: "Cylinder",
props: {
position: [0, 0.55, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#4a8c3f",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 3.1,
radiusBottom: 3,
height: 0.3,
radialSegments: 16,
},
children: [],
},
"main-underhang": {
type: "Cone",
props: {
position: [0, -2.5, 0],
rotation: [PI, 0, 0],
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#6B4513",
metalness: 0.1,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 1.5,
height: 2.5,
radialSegments: 12,
},
children: [],
},
"tree-1-trunk": {
type: "Cylinder",
props: {
position: [1, 1, 0.5],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#8B5A2B",
metalness: 0,
roughness: 1,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 0.06,
radiusBottom: 0.1,
height: 1.5,
radialSegments: 8,
},
children: [],
},
"tree-1-canopy": {
type: "Sphere",
props: {
position: [1, 2.2, 0.5],
rotation: null,
scale: [1, 1.2, 1],
castShadow: true,
receiveShadow: false,
material: {
color: "#2d7a2d",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.6,
widthSegments: 16,
heightSegments: 12,
},
children: [],
},
"tree-2-trunk": {
type: "Cylinder",
props: {
position: [-1.2, 0.8, -0.8],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#8B5A2B",
metalness: 0,
roughness: 1,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 0.05,
radiusBottom: 0.08,
height: 1.1,
radialSegments: 8,
},
children: [],
},
"tree-2-canopy": {
type: "Sphere",
props: {
position: [-1.2, 1.7, -0.8],
rotation: null,
scale: [1, 1.3, 1],
castShadow: true,
receiveShadow: false,
material: {
color: "#3a8c3a",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.45,
widthSegments: 16,
heightSegments: 12,
},
children: [],
},
"tree-3-trunk": {
type: "Cylinder",
props: {
position: [-0.3, 1.2, 1.5],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#8B5A2B",
metalness: 0,
roughness: 1,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 0.07,
radiusBottom: 0.12,
height: 2,
radialSegments: 8,
},
children: [],
},
"tree-3-canopy": {
type: "Sphere",
props: {
position: [-0.3, 2.7, 1.5],
rotation: null,
scale: [1.2, 1.4, 1.2],
castShadow: true,
receiveShadow: false,
material: {
color: "#1a6b1a",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.7,
widthSegments: 16,
heightSegments: 12,
},
children: [],
},
"house-base": {
type: "RoundedBox",
props: {
position: [0, 0.9, -0.5],
rotation: [0, 0.3, 0],
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#e8d8c0",
metalness: 0,
roughness: 0.8,
emissive: "#ffddaa",
emissiveIntensity: 0.05,
opacity: null,
transparent: null,
wireframe: null,
},
width: 0.8,
height: 0.7,
depth: 0.7,
radius: 0.05,
smoothness: 4,
},
children: [],
},
"house-roof": {
type: "Cone",
props: {
position: [0, 1.55, -0.5],
rotation: [0, 1.085, 0],
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#a04030",
metalness: 0.1,
roughness: 0.8,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.6,
height: 0.5,
radialSegments: 4,
},
children: [],
},
"island-left-float": {
type: "Float",
props: {
position: [-6, 0.1, 2],
rotation: null,
scale: null,
speed: 0.6,
rotationIntensity: 0.03,
floatIntensity: 0.5,
enabled: true,
},
children: ["island-left"],
},
"island-left": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"left-base",
"left-top",
"left-under",
"left-tree-t",
"left-tree-c",
],
},
"left-base": {
type: "Cylinder",
props: {
position: [0, -0.3, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 1.8,
radiusBottom: 0.8,
height: 1.2,
radialSegments: 12,
},
children: [],
},
"left-top": {
type: "Cylinder",
props: {
position: [0, 0.25, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#4a8c3f",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 1.85,
radiusBottom: 1.8,
height: 0.2,
radialSegments: 12,
},
children: [],
},
"left-under": {
type: "Cone",
props: {
position: [0, -1.5, 0],
rotation: [PI, 0, 0],
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#6B4513",
metalness: 0.1,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.8,
height: 1.5,
radialSegments: 10,
},
children: [],
},
"left-tree-t": {
type: "Cylinder",
props: {
position: [0.3, 0.8, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#8B5A2B",
metalness: 0,
roughness: 1,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 0.04,
radiusBottom: 0.07,
height: 1,
radialSegments: 8,
},
children: [],
},
"left-tree-c": {
type: "Sphere",
props: {
position: [0.3, 1.6, 0],
rotation: null,
scale: [1, 1.2, 1],
castShadow: true,
receiveShadow: false,
material: {
color: "#2d7a2d",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.4,
widthSegments: 12,
heightSegments: 8,
},
children: [],
},
"island-right-float": {
type: "Float",
props: {
position: [5.5, 0, -3],
rotation: null,
scale: null,
speed: 0.5,
rotationIntensity: 0.02,
floatIntensity: 0.4,
enabled: true,
},
children: ["island-right"],
},
"island-right": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"right-base",
"right-top",
"right-under",
"right-tree-t",
"right-tree-c",
],
},
"right-base": {
type: "Cylinder",
props: {
position: [0, -0.3, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 2,
radiusBottom: 1,
height: 1.5,
radialSegments: 12,
},
children: [],
},
"right-top": {
type: "Cylinder",
props: {
position: [0, 0.4, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#4a8c3f",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 2.05,
radiusBottom: 2,
height: 0.2,
radialSegments: 12,
},
children: [],
},
"right-under": {
type: "Cone",
props: {
position: [0, -2, 0],
rotation: [PI, 0, 0],
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#6B4513",
metalness: 0.1,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 1,
height: 2,
radialSegments: 10,
},
children: [],
},
"right-tree-t": {
type: "Cylinder",
props: {
position: [-0.5, 0.9, 0.3],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#8B5A2B",
metalness: 0,
roughness: 1,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 0.05,
radiusBottom: 0.09,
height: 1.3,
radialSegments: 8,
},
children: [],
},
"right-tree-c": {
type: "Sphere",
props: {
position: [-0.5, 2, 0.3],
rotation: null,
scale: [1, 1.3, 1],
castShadow: true,
receiveShadow: false,
material: {
color: "#3a8c3a",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.5,
widthSegments: 12,
heightSegments: 8,
},
children: [],
},
"island-far-float": {
type: "Float",
props: {
position: [2, 3, -10],
rotation: null,
scale: [0.7, 0.7, 0.7],
speed: 0.3,
rotationIntensity: 0.01,
floatIntensity: 0.3,
enabled: true,
},
children: ["far-base", "far-top", "far-under"],
},
"far-base": {
type: "Cylinder",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 2.5,
radiusBottom: 1.2,
height: 1.5,
radialSegments: 12,
},
children: [],
},
"far-top": {
type: "Cylinder",
props: {
position: [0, 0.65, 0],
rotation: null,
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#3a7a3a",
metalness: 0,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radiusTop: 2.55,
radiusBottom: 2.5,
height: 0.2,
radialSegments: 12,
},
children: [],
},
"far-under": {
type: "Cone",
props: {
position: [0, -1.8, 0],
rotation: [PI, 0, 0],
scale: null,
castShadow: true,
receiveShadow: false,
material: {
color: "#6B4513",
metalness: 0.1,
roughness: 0.95,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 1.2,
height: 2,
radialSegments: 10,
},
children: [],
},
"island-tiny1-float": {
type: "Float",
props: {
position: [-3, 2.5, -5],
rotation: null,
scale: [0.4, 0.4, 0.4],
speed: 0.8,
rotationIntensity: 0.05,
floatIntensity: 0.8,
enabled: true,
},
children: ["tiny1-rock"],
},
"tiny1-rock": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: [1, 0.6, 1],
castShadow: true,
receiveShadow: false,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 1,
widthSegments: 12,
heightSegments: 8,
},
children: [],
},
"island-tiny2-float": {
type: "Float",
props: {
position: [8, -0.5, 1],
rotation: null,
scale: [0.3, 0.3, 0.3],
speed: 1,
rotationIntensity: 0.08,
floatIntensity: 1,
enabled: true,
},
children: ["tiny2-rock"],
},
"tiny2-rock": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: [1.2, 0.5, 0.8],
castShadow: true,
receiveShadow: false,
material: {
color: "#7a5a10",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 1,
widthSegments: 12,
heightSegments: 8,
},
children: [],
},
"bridge-1": {
type: "RoundedBox",
props: {
position: [-3.57, -0.1, 1.19],
rotation: [0, 0.32, 0],
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
width: 3.5,
height: 0.5,
depth: 0.6,
radius: 0.05,
smoothness: 2,
},
children: [],
},
"bridge-2": {
type: "RoundedBox",
props: {
position: [3.19, -0.1, -1.74],
rotation: [0, 0.5, 0],
scale: null,
castShadow: true,
receiveShadow: true,
material: {
color: "#8B6914",
metalness: 0.1,
roughness: 0.9,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
width: 3.0,
height: 0.5,
depth: 0.6,
radius: 0.05,
smoothness: 2,
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: [0, 2, 0],
rotation: null,
scale: [20, 8, 20],
count: 120,
speed: 0.1,
opacity: 0.5,
color: "#ffeeaa",
size: 1.5,
noise: 1,
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 8,
maxDistance: 30,
minPolarAngle: 0.2,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.3,
target: [0, 1, 0],
},
children: [],
},
},
},
};
@@ -0,0 +1,164 @@
import type { Scene } from "./_helpers";
export const hyperspaceTunnel: Scene = {
name: "Hyperspace Tunnel",
description:
"Neon warp tunnel at breakneck speed -- bloom, camera shake, particles streaming past",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"controls",
"tunnel",
"stars",
"sparkles-core",
"sparkles-wide",
"ambient",
"center-light",
"shake",
"post",
"fog",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 0, 2],
rotation: null,
scale: null,
fov: 80,
near: 0.1,
far: 200,
makeDefault: true,
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableRotate: false,
enableZoom: false,
enablePan: false,
},
children: [],
},
tunnel: {
type: "WarpTunnel",
props: {
position: null,
rotation: null,
scale: null,
ringCount: 100,
radius: 3,
length: 50,
speed: 12,
tubeRadius: 0.025,
color1: "#00ffff",
color2: "#ff00ff",
},
children: [],
},
stars: {
type: "Stars",
props: {
radius: 80,
depth: 60,
count: 6000,
factor: 5,
saturation: 0.4,
fade: true,
speed: 3,
},
children: [],
},
"sparkles-core": {
type: "Sparkles",
props: {
position: [0, 0, -10],
rotation: null,
scale: [4, 4, 30],
count: 300,
speed: 3,
opacity: 1,
color: "#00ffff",
size: 2,
noise: 0.5,
},
children: [],
},
"sparkles-wide": {
type: "Sparkles",
props: {
position: [0, 0, -15],
rotation: null,
scale: [6, 6, 40],
count: 200,
speed: 4,
opacity: 0.7,
color: "#ff44ff",
size: 1.5,
noise: 1,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#110033", intensity: 0.2 },
children: [],
},
"center-light": {
type: "PointLight",
props: {
position: [0, 0, -5],
rotation: null,
scale: null,
color: "#00ccff",
intensity: 30,
distance: 30,
decay: 2,
castShadow: false,
},
children: [],
},
shake: {
type: "CameraShake",
props: {
intensity: 0.3,
maxYaw: 0.03,
maxPitch: 0.03,
maxRoll: 0.02,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 2.5,
luminanceThreshold: 0.1,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.3, darkness: 0.9 },
children: [],
},
fog: {
type: "Fog",
props: { color: "#030008", near: 5, far: 50 },
children: [],
},
},
},
};
@@ -0,0 +1,29 @@
import type { Scene } from "./_helpers";
import { hyperspaceTunnel } from "./hyperspace-tunnel";
import { orbitalChaos } from "./orbital-chaos";
import { clockworkOrrery } from "./clockwork-orrery";
import { deepSeaAbyss } from "./deep-sea-abyss";
import { stormCell } from "./storm-cell";
import { perpetualMotion } from "./perpetual-motion";
import { floatingIslands } from "./floating-islands";
import { productShowroom } from "./product-showroom";
import { portalGallery } from "./portal-gallery";
import { pipes } from "./pipes";
import { mystify } from "./mystify";
import { starfield } from "./starfield";
export type { Scene };
export const scenes: Scene[] = [
hyperspaceTunnel,
orbitalChaos,
clockworkOrrery,
deepSeaAbyss,
stormCell,
perpetualMotion,
floatingIslands,
productShowroom,
portalGallery,
pipes,
mystify,
starfield,
];
@@ -0,0 +1,400 @@
import type { Scene } from "./_helpers";
export const mystify: Scene = {
name: "Mystify",
description:
"Neon wireframe torus knots orbiting in the void -- the Windows Mystify screensaver in 3D",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"ambient",
"shape1",
"shape2",
"shape3",
"shape4",
"shape5",
"shape6",
"shape7",
"post",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 0, 10],
rotation: null,
scale: null,
fov: 55,
near: 0.1,
far: 1000,
makeDefault: true,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#111111", intensity: 0.1 },
children: [],
},
shape1: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.5,
radius: 3,
tilt: 0.3,
},
children: ["s1-spin"],
},
"s1-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 1.5,
axis: "x",
},
children: ["s1-body"],
},
"s1-body": {
type: "TorusKnot",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.4,
tube: 0.02,
p: 2,
q: 3,
tubularSegments: 128,
radialSegments: 4,
material: {
color: "#00ffff",
emissive: "#00ffff",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape2: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.4,
radius: 4,
tilt: -0.5,
},
children: ["s2-spin"],
},
"s2-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -1,
axis: "z",
},
children: ["s2-body"],
},
"s2-body": {
type: "Torus",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.6,
tube: 0.015,
radialSegments: 4,
tubularSegments: 128,
material: {
color: "#ff00ff",
emissive: "#ff00ff",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape3: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.7,
radius: 2.5,
tilt: 0.8,
},
children: ["s3-spin"],
},
"s3-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 2,
axis: "y",
},
children: ["s3-body"],
},
"s3-body": {
type: "TorusKnot",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.35,
tube: 0.015,
p: 3,
q: 2,
tubularSegments: 128,
radialSegments: 4,
material: {
color: "#ffff00",
emissive: "#ffff00",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape4: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.6,
radius: 3.5,
tilt: -0.2,
},
children: ["s4-spin"],
},
"s4-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 1.2,
axis: "x",
},
children: ["s4-body"],
},
"s4-body": {
type: "Torus",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.5,
tube: 0.012,
radialSegments: 4,
tubularSegments: 128,
material: {
color: "#00ff44",
emissive: "#00ff44",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape5: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.3,
radius: 5,
tilt: 0.6,
},
children: ["s5-spin"],
},
"s5-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -1.8,
axis: "z",
},
children: ["s5-body"],
},
"s5-body": {
type: "TorusKnot",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.5,
tube: 0.018,
p: 2,
q: 5,
tubularSegments: 128,
radialSegments: 4,
material: {
color: "#ff4400",
emissive: "#ff4400",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape6: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.8,
radius: 2,
tilt: -0.7,
},
children: ["s6-spin"],
},
"s6-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 2.5,
axis: "y",
},
children: ["s6-body"],
},
"s6-body": {
type: "Torus",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.3,
tube: 0.01,
radialSegments: 4,
tubularSegments: 128,
material: {
color: "#4488ff",
emissive: "#4488ff",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
shape7: {
type: "Orbit",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.45,
radius: 4.5,
tilt: 1,
},
children: ["s7-spin"],
},
"s7-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.8,
axis: "x",
},
children: ["s7-body"],
},
"s7-body": {
type: "TorusKnot",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.45,
tube: 0.015,
p: 3,
q: 4,
tubularSegments: 128,
radialSegments: 4,
material: {
color: "#ff88ff",
emissive: "#ff88ff",
emissiveIntensity: 2,
wireframe: true,
},
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 3,
luminanceThreshold: 0.05,
luminanceSmoothing: 0.3,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.2, darkness: 0.8 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: true,
enableRotate: true,
minDistance: 4,
maxDistance: 18,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: true,
autoRotateSpeed: 0.3,
},
children: [],
},
},
},
};
@@ -0,0 +1,554 @@
import { PI } from "./_helpers";
import type { Scene } from "./_helpers";
export const orbitalChaos: Scene = {
name: "Orbital Chaos",
description:
"Everything in motion -- orbiting glass, spinning knots, morphing core, all with bloom",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"env",
"stars",
"ambient",
"core-light",
"accent-1",
"accent-2",
"core-pulse",
"orbit-1",
"orbit-2",
"orbit-3",
"orbit-4",
"orbit-5",
"orbit-6",
"ring-spin-fast",
"ring-spin-slow",
"sparkles",
"post",
"fog",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 2, 8],
fov: 50,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: { preset: "night", background: false, blur: 1, intensity: 0.15 },
children: [],
},
stars: {
type: "Stars",
props: {
radius: 120,
depth: 60,
count: 8000,
factor: 5,
saturation: 0.2,
fade: true,
speed: 0.5,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#110022", intensity: 0.3 },
children: [],
},
"core-light": {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#ff44aa",
intensity: 80,
distance: 25,
decay: 2,
castShadow: false,
},
children: [],
},
"accent-1": {
type: "PointLight",
props: {
position: [5, 3, 5],
rotation: null,
scale: null,
color: "#00ffff",
intensity: 25,
distance: 20,
decay: 2,
castShadow: false,
},
children: [],
},
"accent-2": {
type: "PointLight",
props: {
position: [-5, -2, -5],
rotation: null,
scale: null,
color: "#ff00ff",
intensity: 25,
distance: 20,
decay: 2,
castShadow: false,
},
children: [],
},
"core-pulse": {
type: "Pulse",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.8,
min: 0.85,
max: 1.15,
},
children: ["core-spin"],
},
"core-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.3,
axis: "y",
},
children: ["core-blob"],
},
"core-blob": {
type: "DistortSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 1.2,
widthSegments: 64,
heightSegments: 32,
color: "#ff2288",
speed: 4,
distort: 0.6,
metalness: 0.1,
roughness: 0.1,
},
children: [],
},
"orbit-1": {
type: "Orbit",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: 0.8,
radius: 4,
tilt: 0.5,
},
children: ["orb-1-spin"],
},
"orb-1-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 2,
axis: "x",
},
children: ["orb-1"],
},
"orb-1": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.4,
widthSegments: 32,
heightSegments: 16,
color: "#00ffff",
transmission: 1,
thickness: 0.5,
roughness: 0,
chromaticAberration: 0.15,
ior: 1.8,
distortion: 0.1,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"orbit-2": {
type: "Orbit",
props: {
position: [0, 0.5, 0],
rotation: null,
scale: null,
speed: -0.6,
radius: 5.5,
tilt: 1,
},
children: ["orb-2-spin"],
},
"orb-2-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 3,
axis: "z",
},
children: ["orb-2"],
},
"orb-2": {
type: "TorusKnot",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ff00ff",
metalness: 0.9,
roughness: 0.1,
emissive: "#ff00ff",
emissiveIntensity: 0.8,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 0.35,
tube: 0.12,
tubularSegments: 64,
radialSegments: 8,
p: 2,
q: 3,
},
children: [],
},
"orbit-3": {
type: "Orbit",
props: {
position: [0, -0.3, 0],
rotation: null,
scale: null,
speed: 1.2,
radius: 3.2,
tilt: 0.3,
},
children: ["orb-3"],
},
"orb-3": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.3,
widthSegments: 32,
heightSegments: 16,
color: "#ff8800",
transmission: 1,
thickness: 0.4,
roughness: 0,
chromaticAberration: 0.1,
ior: 1.6,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"orbit-4": {
type: "Orbit",
props: {
position: [0, 1, 0],
rotation: null,
scale: null,
speed: -1.5,
radius: 6.5,
tilt: -0.8,
},
children: ["orb-4-spin"],
},
"orb-4-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -4,
axis: "y",
},
children: ["orb-4"],
},
"orb-4": {
type: "Sphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#00ff88",
metalness: null,
roughness: null,
emissive: "#00ff88",
emissiveIntensity: 0.8,
opacity: null,
transparent: null,
wireframe: true,
},
radius: 0.35,
widthSegments: 16,
heightSegments: 12,
},
children: [],
},
"orbit-5": {
type: "Orbit",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
speed: 0.4,
radius: 8,
tilt: 0.2,
},
children: ["orb-5-spin"],
},
"orb-5-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 1.5,
axis: "z",
},
children: ["orb-5"],
},
"orb-5": {
type: "GlassBox",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
width: 0.5,
height: 0.5,
depth: 0.5,
color: "#ff44ff",
transmission: 1,
thickness: 0.4,
roughness: 0,
chromaticAberration: 0.08,
ior: 1.7,
distortion: 0,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
"orbit-6": {
type: "Orbit",
props: {
position: [0, 0.8, 0],
rotation: null,
scale: null,
speed: 2,
radius: 2.2,
tilt: 1.5,
},
children: ["orb-6"],
},
"orb-6": {
type: "DistortSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: false,
receiveShadow: false,
radius: 0.25,
widthSegments: 32,
heightSegments: 16,
color: "#ffcc00",
speed: 5,
distort: 0.7,
metalness: 0.5,
roughness: 0.1,
},
children: [],
},
"ring-spin-fast": {
type: "Spin",
props: {
position: null,
rotation: [0.3, 0, 0],
scale: null,
speed: 0.5,
axis: "y",
},
children: ["ring-inner"],
},
"ring-inner": {
type: "Torus",
props: {
position: null,
rotation: [PI / 2, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#00ffff",
metalness: 0.9,
roughness: 0.1,
emissive: "#00ccff",
emissiveIntensity: 0.5,
opacity: 0.3,
transparent: true,
wireframe: null,
},
radius: 9,
tube: 0.015,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"ring-spin-slow": {
type: "Spin",
props: {
position: null,
rotation: [-0.5, 0, 0.3],
scale: null,
speed: -0.3,
axis: "y",
},
children: ["ring-outer"],
},
"ring-outer": {
type: "Torus",
props: {
position: null,
rotation: [PI / 2, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ff00ff",
metalness: 0.9,
roughness: 0.1,
emissive: "#ff00aa",
emissiveIntensity: 0.3,
opacity: 0.2,
transparent: true,
wireframe: null,
},
radius: 11,
tube: 0.012,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: null,
rotation: null,
scale: [18, 18, 18],
count: 400,
speed: 0.4,
opacity: 0.5,
color: "#ffddaa",
size: 1.2,
noise: 2,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 2,
luminanceThreshold: 0.15,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.4, darkness: 0.7 },
children: [],
},
fog: {
type: "Fog",
props: { color: "#030008", near: 10, far: 40 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 5,
maxDistance: 25,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: true,
autoRotateSpeed: 0.4,
},
children: [],
},
},
},
};
@@ -0,0 +1,307 @@
import { PI } from "./_helpers";
import type { Scene } from "./_helpers";
export const perpetualMotion: Scene = {
name: "Perpetual Motion Machine",
description:
"A gyroscope/gimbal with 3 nested spinning rings on different axes, with a perfectly still glass sphere floating at the center. Clean, minimal, mesmerizing.",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"env",
"ambient",
"key",
"fill",
"outer-spin",
"sparkles",
"floor",
"shadows",
"post",
"controls",
],
},
env: {
type: "Environment",
props: {
preset: "studio",
background: false,
blur: null,
intensity: 1,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#ffffff", intensity: 0.3 },
children: [],
},
key: {
type: "DirectionalLight",
props: {
position: [5, 8, 5],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 2,
castShadow: true,
},
children: [],
},
fill: {
type: "PointLight",
props: {
position: [-3, 4, -3],
rotation: null,
scale: null,
color: "#aaccff",
intensity: 10,
distance: 15,
decay: 2,
castShadow: false,
},
children: [],
},
"outer-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.4,
axis: "y",
},
children: ["outer-ring", "mid-spin"],
},
"outer-ring": {
type: "Torus",
props: {
position: null,
rotation: [PI / 2, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#cccccc",
metalness: 1,
roughness: 0.05,
emissive: "#ffffff",
emissiveIntensity: 0.05,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 4,
tube: 0.06,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"mid-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: -0.8,
axis: "x",
},
children: ["mid-ring", "inner-spin"],
},
"mid-ring": {
type: "Torus",
props: {
position: null,
rotation: [PI / 2, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#ffcc44",
metalness: 0.95,
roughness: 0.08,
emissive: "#cc9900",
emissiveIntensity: 0.1,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 3,
tube: 0.05,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"inner-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 1.5,
axis: "z",
},
children: ["inner-ring", "center-float"],
},
"inner-ring": {
type: "Torus",
props: {
position: null,
rotation: [PI / 2, 0, 0],
scale: null,
castShadow: false,
receiveShadow: false,
material: {
color: "#cc8844",
metalness: 0.9,
roughness: 0.1,
emissive: "#996633",
emissiveIntensity: 0.1,
opacity: null,
transparent: null,
wireframe: null,
},
radius: 2,
tube: 0.04,
radialSegments: null,
tubularSegments: 128,
},
children: [],
},
"center-float": {
type: "Float",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
speed: 0.3,
rotationIntensity: 0,
floatIntensity: 0.1,
enabled: true,
},
children: ["center-orb"],
},
"center-orb": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: true,
receiveShadow: false,
radius: 0.8,
widthSegments: 64,
heightSegments: 32,
color: "#ffffff",
transmission: 1,
thickness: 1,
roughness: 0,
chromaticAberration: 0.12,
ior: 2,
distortion: 0.1,
distortionScale: 0.4,
temporalDistortion: 0.4,
samples: 10,
resolution: 256,
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: [0, 0, 0],
rotation: null,
scale: [10, 10, 10],
count: 100,
speed: 0.1,
opacity: 0.2,
color: "#ccddff",
size: 0.8,
noise: 2,
},
children: [],
},
floor: {
type: "ReflectorPlane",
props: {
position: [0, -4.5, 0],
rotation: [-PI / 2, 0, 0],
scale: null,
width: 25,
height: 25,
color: "#1a1a2a",
resolution: 1024,
blur: 400,
mirror: 0.7,
mixBlur: 10,
mixStrength: 2,
depthScale: 0.2,
metalness: 0.5,
roughness: 1,
},
children: [],
},
shadows: {
type: "ContactShadows",
props: {
position: [0, -4.5, 0],
rotation: null,
scale: null,
opacity: 0.3,
width: 12,
height: 12,
blur: 2.5,
near: null,
far: 10,
smooth: true,
resolution: 512,
frames: null,
color: "#000000",
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.8,
luminanceThreshold: 0.25,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.5, darkness: 0.4 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 5,
maxDistance: 16,
minPolarAngle: 0.3,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.3,
},
children: [],
},
},
},
};
@@ -0,0 +1,764 @@
import type { Scene } from "./_helpers";
const goldMaterial = {
color: "#cc8833",
metalness: 1,
roughness: 0.05,
};
const blueMaterial = {
color: "#3388cc",
metalness: 1,
roughness: 0.05,
};
const magentaMaterial = {
color: "#cc3388",
metalness: 1,
roughness: 0.05,
};
export const pipes: Scene = {
name: "Pipes",
description:
"Classic 3D Pipes screensaver -- chrome pipes snaking through space at right angles",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"env",
"ambient",
"directional",
"pipe-system",
"post",
"fog",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [0, 3, 12],
rotation: null,
scale: null,
fov: 50,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "warehouse",
background: false,
blur: 0.5,
intensity: 1,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#ffffff", intensity: 0.3 },
children: [],
},
directional: {
type: "DirectionalLight",
props: {
position: [5, 10, 5],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 1,
castShadow: null,
},
children: [],
},
"pipe-system": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.1,
axis: "y",
},
children: ["pipe1-group", "pipe2-group", "pipe3-group"],
},
"pipe1-group": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"p1-j0",
"p1-s1",
"p1-j1",
"p1-s2",
"p1-j2",
"p1-s3",
"p1-j3",
"p1-s4",
"p1-j4",
"p1-s5",
"p1-j5",
"p1-s6",
"p1-j6",
],
},
"p1-j0": {
type: "Sphere",
props: {
position: [-3, -3, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j1": {
type: "Sphere",
props: {
position: [-3, 0, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j2": {
type: "Sphere",
props: {
position: [1, 0, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j3": {
type: "Sphere",
props: {
position: [1, 2, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j4": {
type: "Sphere",
props: {
position: [1, 2, -3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j5": {
type: "Sphere",
props: {
position: [1, 0, -3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-j6": {
type: "Sphere",
props: {
position: [-1, 0, -3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p1-s1": {
type: "Cylinder",
props: {
position: [-3, -1.5, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"p1-s2": {
type: "Cylinder",
props: {
position: [-1, 0, 0],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 4,
radialSegments: 12,
},
children: [],
},
"p1-s3": {
type: "Cylinder",
props: {
position: [1, 1, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 2,
radialSegments: 12,
},
children: [],
},
"p1-s4": {
type: "Cylinder",
props: {
position: [1, 2, -1.5],
rotation: [1.5708, 0, 0],
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"p1-s5": {
type: "Cylinder",
props: {
position: [1, 1, -3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 2,
radialSegments: 12,
},
children: [],
},
"p1-s6": {
type: "Cylinder",
props: {
position: [0, 0, -3],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: goldMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 2,
radialSegments: 12,
},
children: [],
},
"pipe2-group": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"p2-j0",
"p2-s1",
"p2-j1",
"p2-s2",
"p2-j2",
"p2-s3",
"p2-j3",
"p2-s4",
"p2-j4",
"p2-s5",
"p2-j5",
"p2-s6",
"p2-j6",
],
},
"p2-j0": {
type: "Sphere",
props: {
position: [2, -3, 2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j1": {
type: "Sphere",
props: {
position: [2, 1, 2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j2": {
type: "Sphere",
props: {
position: [2, 1, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j3": {
type: "Sphere",
props: {
position: [-1, 1, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j4": {
type: "Sphere",
props: {
position: [-1, 4, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j5": {
type: "Sphere",
props: {
position: [-3, 4, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-j6": {
type: "Sphere",
props: {
position: [-3, 4, 1],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p2-s1": {
type: "Cylinder",
props: {
position: [2, -1, 2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 4,
radialSegments: 12,
},
children: [],
},
"p2-s2": {
type: "Cylinder",
props: {
position: [2, 1, 0],
rotation: [1.5708, 0, 0],
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 4,
radialSegments: 12,
},
children: [],
},
"p2-s3": {
type: "Cylinder",
props: {
position: [0.5, 1, -2],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"p2-s4": {
type: "Cylinder",
props: {
position: [-1, 2.5, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"p2-s5": {
type: "Cylinder",
props: {
position: [-2, 4, -2],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 2,
radialSegments: 12,
},
children: [],
},
"p2-s6": {
type: "Cylinder",
props: {
position: [-3, 4, -0.5],
rotation: [1.5708, 0, 0],
scale: null,
castShadow: null,
receiveShadow: null,
material: blueMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"pipe3-group": {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"p3-j0",
"p3-s1",
"p3-j1",
"p3-s2",
"p3-j2",
"p3-s3",
"p3-j3",
"p3-s4",
"p3-j4",
"p3-s5",
"p3-j5",
],
},
"p3-j0": {
type: "Sphere",
props: {
position: [-2, 3, 3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-j1": {
type: "Sphere",
props: {
position: [-2, -2, 3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-j2": {
type: "Sphere",
props: {
position: [1, -2, 3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-j3": {
type: "Sphere",
props: {
position: [1, -2, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-j4": {
type: "Sphere",
props: {
position: [1, 0, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-j5": {
type: "Sphere",
props: {
position: [-3, 0, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radius: 0.12,
widthSegments: null,
heightSegments: null,
},
children: [],
},
"p3-s1": {
type: "Cylinder",
props: {
position: [-2, 0.5, 3],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 5,
radialSegments: 12,
},
children: [],
},
"p3-s2": {
type: "Cylinder",
props: {
position: [-0.5, -2, 3],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 3,
radialSegments: 12,
},
children: [],
},
"p3-s3": {
type: "Cylinder",
props: {
position: [1, -2, 0.5],
rotation: [1.5708, 0, 0],
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 5,
radialSegments: 12,
},
children: [],
},
"p3-s4": {
type: "Cylinder",
props: {
position: [1, -1, -2],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 2,
radialSegments: 12,
},
children: [],
},
"p3-s5": {
type: "Cylinder",
props: {
position: [-1, 0, -2],
rotation: [0, 0, 1.5708],
scale: null,
castShadow: null,
receiveShadow: null,
material: magentaMaterial,
radiusTop: 0.08,
radiusBottom: 0.08,
height: 4,
radialSegments: 12,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.4,
luminanceThreshold: 0.4,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.3, darkness: 0.4 },
children: [],
},
fog: {
type: "Fog",
props: { color: "#111111", near: 10, far: 30 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 5,
maxDistance: 20,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: true,
autoRotateSpeed: 0.6,
},
children: [],
},
},
},
};
@@ -0,0 +1,809 @@
import type { Scene } from "./_helpers";
export const portalGallery: Scene = {
name: "Portal Gallery",
description:
"Three framed portals into different worlds -- sunset clouds, starfield, and enchanted forest",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"env",
"ambient",
"directional",
"floor",
"shadows",
"frame1",
"frame2",
"frame3",
"gallery-sparkles",
"post",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 2, 7],
rotation: null,
scale: null,
fov: 50,
near: 0.1,
far: 200,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "lobby",
background: false,
blur: 0,
intensity: 0.4,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#e8e0d8", intensity: 0.5 },
children: [],
},
directional: {
type: "DirectionalLight",
props: {
position: [5, 8, 3],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 1,
castShadow: true,
},
children: [],
},
floor: {
type: "ReflectorPlane",
props: {
position: [0, 0, 0],
rotation: [-1.5708, 0, 0],
scale: null,
width: 30,
height: 30,
color: "#2a2a2a",
resolution: 1024,
blur: 400,
mirror: 0.4,
mixBlur: 10,
mixStrength: 1.5,
metalness: 0.5,
roughness: 0.8,
},
children: [],
},
shadows: {
type: "ContactShadows",
props: {
position: [0, 0.01, 0],
rotation: null,
scale: null,
opacity: 0.4,
width: 20,
height: 20,
blur: 2.5,
far: 8,
color: "#000000",
},
children: [],
},
// --- PORTAL 1 (left, sunset world) ---
frame1: {
type: "Group",
props: {
position: [-3.5, 2, -2],
rotation: [0, 0.3, 0],
scale: null,
},
children: [
"frame1-top",
"frame1-bottom",
"frame1-left",
"frame1-right",
"portal1-mesh",
],
},
"frame1-top": {
type: "Box",
props: {
position: [0, 1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#8B7355", metalness: 0.3, roughness: 0.6 },
},
children: [],
},
"frame1-bottom": {
type: "Box",
props: {
position: [0, -1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#8B7355", metalness: 0.3, roughness: 0.6 },
},
children: [],
},
"frame1-left": {
type: "Box",
props: {
position: [-1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#8B7355", metalness: 0.3, roughness: 0.6 },
},
children: [],
},
"frame1-right": {
type: "Box",
props: {
position: [1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#8B7355", metalness: 0.3, roughness: 0.6 },
},
children: [],
},
"portal1-mesh": {
type: "Plane",
props: {
position: null,
rotation: null,
scale: null,
width: 2,
height: 3,
},
children: ["portal1-mat"],
},
"portal1-mat": {
type: "MeshPortalMaterial",
props: { blend: 0, blur: 0.5, resolution: 512 },
children: [
"p1-sky",
"p1-sun-light",
"p1-cloud1",
"p1-cloud2",
"p1-cloud3",
"p1-ground",
],
},
"p1-sky": {
type: "Sky",
props: {
distance: null,
sunPosition: [100, 10, 50],
inclination: null,
azimuth: null,
rayleigh: 2,
turbidity: 10,
mieCoefficient: 0.005,
mieDirectionalG: null,
},
children: [],
},
"p1-sun-light": {
type: "DirectionalLight",
props: {
position: [5, 5, 3],
rotation: null,
scale: null,
color: "#ffaa55",
intensity: 2,
castShadow: false,
},
children: [],
},
"p1-cloud1": {
type: "Cloud",
props: {
position: [-3, 4, -5],
rotation: null,
scale: null,
seed: 1,
segments: 20,
bounds: [6, 2, 3],
opacity: 0.6,
color: "#ffddaa",
speed: 0.1,
volume: 5,
fade: null,
growth: null,
},
children: [],
},
"p1-cloud2": {
type: "Cloud",
props: {
position: [4, 5, -8],
rotation: null,
scale: null,
seed: 7,
segments: 25,
bounds: [8, 2, 4],
opacity: 0.5,
color: "#ffccaa",
speed: 0.08,
volume: 6,
fade: null,
growth: null,
},
children: [],
},
"p1-cloud3": {
type: "Cloud",
props: {
position: [0, 3.5, -4],
rotation: null,
scale: null,
seed: 14,
segments: 15,
bounds: [5, 1.5, 2.5],
opacity: 0.7,
color: "#ffeedd",
speed: 0.12,
volume: 4,
fade: null,
growth: null,
},
children: [],
},
"p1-ground": {
type: "Plane",
props: {
position: [0, -2, 0],
rotation: [-1.5708, 0, 0],
scale: null,
width: 30,
height: 30,
material: { color: "#886644", roughness: 1 },
},
children: [],
},
// --- PORTAL 2 (center, night/stars world) ---
frame2: {
type: "Group",
props: {
position: [0, 2, -3],
rotation: null,
scale: null,
},
children: [
"frame2-top",
"frame2-bottom",
"frame2-left",
"frame2-right",
"portal2-mesh",
],
},
"frame2-top": {
type: "Box",
props: {
position: [0, 1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#555566", metalness: 0.8, roughness: 0.2 },
},
children: [],
},
"frame2-bottom": {
type: "Box",
props: {
position: [0, -1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#555566", metalness: 0.8, roughness: 0.2 },
},
children: [],
},
"frame2-left": {
type: "Box",
props: {
position: [-1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#555566", metalness: 0.8, roughness: 0.2 },
},
children: [],
},
"frame2-right": {
type: "Box",
props: {
position: [1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#555566", metalness: 0.8, roughness: 0.2 },
},
children: [],
},
"portal2-mesh": {
type: "Plane",
props: {
position: null,
rotation: null,
scale: null,
width: 2,
height: 3,
},
children: ["portal2-mat"],
},
"portal2-mat": {
type: "MeshPortalMaterial",
props: { blend: 0, blur: 0.5, resolution: 512 },
children: [
"p2-stars",
"p2-ambient",
"p2-nebula1",
"p2-nebula2",
"p2-sparkles",
],
},
"p2-stars": {
type: "Stars",
props: {
radius: 80,
depth: 40,
count: 6000,
factor: 5,
saturation: 0.5,
fade: true,
speed: 0.3,
},
children: [],
},
"p2-ambient": {
type: "AmbientLight",
props: { color: "#111133", intensity: 0.3 },
children: [],
},
"p2-nebula1": {
type: "Sphere",
props: {
position: [3, 2, -8],
rotation: null,
scale: null,
radius: 2,
widthSegments: null,
heightSegments: null,
material: {
color: "#4400aa",
emissive: "#4400aa",
emissiveIntensity: 0.5,
transparent: true,
opacity: 0.3,
},
},
children: [],
},
"p2-nebula2": {
type: "Sphere",
props: {
position: [-4, -1, -10],
rotation: null,
scale: null,
radius: 3,
widthSegments: null,
heightSegments: null,
material: {
color: "#aa0044",
emissive: "#aa0044",
emissiveIntensity: 0.4,
transparent: true,
opacity: 0.25,
},
},
children: [],
},
"p2-sparkles": {
type: "Sparkles",
props: {
position: [0, 0, -5],
rotation: null,
scale: [10, 10, 10],
count: 200,
speed: 0.2,
opacity: 0.8,
color: "#aabbff",
size: 2,
noise: 3,
},
children: [],
},
// --- PORTAL 3 (right, forest/nature world) ---
frame3: {
type: "Group",
props: {
position: [3.5, 2, -2],
rotation: [0, -0.3, 0],
scale: null,
},
children: [
"frame3-top",
"frame3-bottom",
"frame3-left",
"frame3-right",
"portal3-mesh",
],
},
"frame3-top": {
type: "Box",
props: {
position: [0, 1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#6B4423", metalness: 0.1, roughness: 0.8 },
},
children: [],
},
"frame3-bottom": {
type: "Box",
props: {
position: [0, -1.55, 0],
rotation: null,
scale: null,
width: 2.3,
height: 0.15,
depth: 0.15,
material: { color: "#6B4423", metalness: 0.1, roughness: 0.8 },
},
children: [],
},
"frame3-left": {
type: "Box",
props: {
position: [-1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#6B4423", metalness: 0.1, roughness: 0.8 },
},
children: [],
},
"frame3-right": {
type: "Box",
props: {
position: [1.075, 0, 0],
rotation: null,
scale: null,
width: 0.15,
height: 3.25,
depth: 0.15,
material: { color: "#6B4423", metalness: 0.1, roughness: 0.8 },
},
children: [],
},
"portal3-mesh": {
type: "Plane",
props: {
position: null,
rotation: null,
scale: null,
width: 2,
height: 3,
},
children: ["portal3-mat"],
},
"portal3-mat": {
type: "MeshPortalMaterial",
props: { blend: 0, blur: 0.5, resolution: 512 },
children: [
"p3-env",
"p3-ambient",
"p3-sun",
"p3-tree1",
"p3-tree2",
"p3-tree3",
"p3-tree4",
"p3-tree5",
"p3-ground",
"p3-fireflies",
],
},
"p3-env": {
type: "Environment",
props: { preset: "forest", background: false, blur: 0, intensity: 0.8 },
children: [],
},
"p3-ambient": {
type: "AmbientLight",
props: { color: "#334422", intensity: 0.4 },
children: [],
},
"p3-sun": {
type: "DirectionalLight",
props: {
position: [-3, 8, 2],
rotation: null,
scale: null,
color: "#aaffaa",
intensity: 1.5,
castShadow: true,
},
children: [],
},
"p3-tree1": {
type: "Group",
props: { position: [-2, -2, -4], rotation: null, scale: null },
children: ["p3-t1-trunk", "p3-t1-leaves"],
},
"p3-t1-trunk": {
type: "Cylinder",
props: {
position: [0, 0.6, 0],
rotation: null,
scale: null,
radiusTop: 0.08,
radiusBottom: 0.12,
height: 1.2,
radialSegments: 8,
material: { color: "#4a3520", roughness: 0.9 },
},
children: [],
},
"p3-t1-leaves": {
type: "Cone",
props: {
position: [0, 1.8, 0],
rotation: null,
scale: null,
radius: 0.6,
height: 1.5,
radialSegments: 8,
material: { color: "#2d5a1e", roughness: 0.8 },
},
children: [],
},
"p3-tree2": {
type: "Group",
props: { position: [1.5, -2, -6], rotation: null, scale: null },
children: ["p3-t2-trunk", "p3-t2-leaves"],
},
"p3-t2-trunk": {
type: "Cylinder",
props: {
position: [0, 0.8, 0],
rotation: null,
scale: null,
radiusTop: 0.1,
radiusBottom: 0.15,
height: 1.6,
radialSegments: 8,
material: { color: "#3d2b15", roughness: 0.9 },
},
children: [],
},
"p3-t2-leaves": {
type: "Cone",
props: {
position: [0, 2.3, 0],
rotation: null,
scale: null,
radius: 0.8,
height: 2,
radialSegments: 8,
material: { color: "#1e4a10", roughness: 0.8 },
},
children: [],
},
"p3-tree3": {
type: "Group",
props: { position: [-0.5, -2, -3], rotation: null, scale: null },
children: ["p3-t3-trunk", "p3-t3-leaves"],
},
"p3-t3-trunk": {
type: "Cylinder",
props: {
position: [0, 0.5, 0],
rotation: null,
scale: null,
radiusTop: 0.06,
radiusBottom: 0.1,
height: 1,
radialSegments: 8,
material: { color: "#4a3520", roughness: 0.9 },
},
children: [],
},
"p3-t3-leaves": {
type: "Cone",
props: {
position: [0, 1.5, 0],
rotation: null,
scale: null,
radius: 0.5,
height: 1.2,
radialSegments: 8,
material: { color: "#3a6b2a", roughness: 0.8 },
},
children: [],
},
"p3-tree4": {
type: "Group",
props: { position: [3, -2, -8], rotation: null, scale: null },
children: ["p3-t4-trunk", "p3-t4-leaves"],
},
"p3-t4-trunk": {
type: "Cylinder",
props: {
position: [0, 1, 0],
rotation: null,
scale: null,
radiusTop: 0.12,
radiusBottom: 0.18,
height: 2,
radialSegments: 8,
material: { color: "#3d2b15", roughness: 0.9 },
},
children: [],
},
"p3-t4-leaves": {
type: "Cone",
props: {
position: [0, 2.8, 0],
rotation: null,
scale: null,
radius: 1,
height: 2.5,
radialSegments: 8,
material: { color: "#2d5a1e", roughness: 0.8 },
},
children: [],
},
"p3-tree5": {
type: "Group",
props: { position: [-3.5, -2, -7], rotation: null, scale: null },
children: ["p3-t5-trunk", "p3-t5-leaves"],
},
"p3-t5-trunk": {
type: "Cylinder",
props: {
position: [0, 0.7, 0],
rotation: null,
scale: null,
radiusTop: 0.09,
radiusBottom: 0.13,
height: 1.4,
radialSegments: 8,
material: { color: "#4a3520", roughness: 0.9 },
},
children: [],
},
"p3-t5-leaves": {
type: "Cone",
props: {
position: [0, 2, 0],
rotation: null,
scale: null,
radius: 0.7,
height: 1.8,
radialSegments: 8,
material: { color: "#1e4a10", roughness: 0.8 },
},
children: [],
},
"p3-ground": {
type: "Plane",
props: {
position: [0, -2, 0],
rotation: [-1.5708, 0, 0],
scale: null,
width: 30,
height: 30,
material: { color: "#3a5a2a", roughness: 1 },
},
children: [],
},
"p3-fireflies": {
type: "Sparkles",
props: {
position: [0, 0, -5],
rotation: null,
scale: [6, 4, 6],
count: 80,
speed: 0.3,
opacity: 0.7,
color: "#aaff66",
size: 1.5,
noise: 2,
},
children: [],
},
"gallery-sparkles": {
type: "Sparkles",
props: {
position: [0, 3, 0],
rotation: null,
scale: [12, 5, 10],
count: 40,
speed: 0.05,
opacity: 0.2,
color: "#ffffff",
size: 0.3,
noise: null,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.6,
luminanceThreshold: 0.3,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.4, darkness: 0.4 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 4,
maxDistance: 16,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: true,
autoRotateSpeed: 0.3,
target: [0, 1.8, -2],
},
children: [],
},
},
},
};
@@ -0,0 +1,455 @@
import type { Scene } from "./_helpers";
export const productShowroom: Scene = {
name: "Product Showroom",
description:
"Sleek glass torus knot on a pedestal with studio lighting, reflections, and contact shadows",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"env",
"backdrop",
"ambient",
"key-light",
"fill-light",
"rim-light",
"accent-light",
"pedestal",
"hero-float",
"accent1",
"accent2",
"accent3",
"floor",
"shadows",
"sparkles",
"fx",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [0, 2, 6],
rotation: null,
scale: null,
fov: 40,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "studio",
background: false,
blur: 0.5,
intensity: 0.8,
},
children: [],
},
backdrop: {
type: "Backdrop",
props: {
position: [0, -0.5, -3],
rotation: null,
scale: [25, 10, 8],
floor: 0.3,
segments: 30,
receiveShadow: true,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#e8e0f0", intensity: 0.25 },
children: [],
},
"key-light": {
type: "SpotLight",
props: {
position: [5, 8, 3],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 80,
distance: 25,
decay: null,
angle: 0.5,
penumbra: 0.8,
castShadow: true,
},
children: [],
},
"fill-light": {
type: "SpotLight",
props: {
position: [-4, 5, 2],
rotation: null,
scale: null,
color: "#aaccff",
intensity: 30,
distance: 20,
decay: null,
angle: 0.6,
penumbra: 1,
castShadow: false,
},
children: [],
},
"rim-light": {
type: "SpotLight",
props: {
position: [0, 3, -5],
rotation: null,
scale: null,
color: "#ffddcc",
intensity: 40,
distance: 15,
decay: null,
angle: 0.4,
penumbra: 0.5,
castShadow: false,
},
children: [],
},
"accent-light": {
type: "PointLight",
props: {
position: [0, 0.5, 0],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 5,
distance: 4,
decay: null,
castShadow: null,
},
children: [],
},
pedestal: {
type: "Group",
props: { position: [0, 0, 0], rotation: null, scale: null },
children: ["ped-base", "ped-column", "ped-top"],
},
"ped-base": {
type: "Cylinder",
props: {
position: [0, 0.05, 0],
rotation: null,
scale: null,
radiusTop: 1.2,
radiusBottom: 1.3,
height: 0.1,
radialSegments: 48,
castShadow: null,
receiveShadow: null,
material: {
color: "#1a1a1a",
metalness: 0.9,
roughness: 0.1,
},
},
children: [],
},
"ped-column": {
type: "Cylinder",
props: {
position: [0, 0.45, 0],
rotation: null,
scale: null,
radiusTop: 0.6,
radiusBottom: 0.8,
height: 0.7,
radialSegments: 48,
castShadow: null,
receiveShadow: null,
material: {
color: "#111111",
metalness: 0.95,
roughness: 0.05,
},
},
children: [],
},
"ped-top": {
type: "Cylinder",
props: {
position: [0, 0.85, 0],
rotation: null,
scale: null,
radiusTop: 0.9,
radiusBottom: 0.7,
height: 0.1,
radialSegments: 48,
castShadow: null,
receiveShadow: null,
material: {
color: "#1a1a1a",
metalness: 0.9,
roughness: 0.1,
},
},
children: [],
},
"hero-float": {
type: "Float",
props: {
position: [0, 2.2, 0],
rotation: null,
scale: null,
speed: 1.5,
rotationIntensity: 0.3,
floatIntensity: 0.4,
enabled: true,
},
children: ["hero-spin"],
},
"hero-spin": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.3,
axis: "y",
},
children: ["hero-object"],
},
"hero-object": {
type: "TorusKnot",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.6,
tube: 0.22,
p: 2,
q: 3,
tubularSegments: 128,
radialSegments: 24,
material: {
color: "#ffffff",
metalness: 1,
roughness: 0,
},
},
children: [],
},
accent1: {
type: "Float",
props: {
position: [-1.5, 1.8, 0.5],
rotation: null,
scale: null,
speed: 2,
rotationIntensity: 0.5,
floatIntensity: 1.2,
enabled: true,
},
children: ["accent1-body"],
},
"accent1-body": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.15,
widthSegments: null,
heightSegments: null,
color: "#eeeeff",
transmission: 1,
thickness: 0.3,
roughness: 0,
chromaticAberration: 0.06,
ior: 1.5,
distortion: null,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
accent2: {
type: "Float",
props: {
position: [1.2, 2.5, -0.8],
rotation: null,
scale: null,
speed: 1.8,
rotationIntensity: 0.4,
floatIntensity: 1.5,
enabled: true,
},
children: ["accent2-body"],
},
"accent2-body": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.1,
widthSegments: null,
heightSegments: null,
color: "#ffeeff",
transmission: 1,
thickness: 0.2,
roughness: 0,
chromaticAberration: 0.08,
ior: 1.6,
distortion: null,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
accent3: {
type: "Float",
props: {
position: [0.8, 1.5, 1.2],
rotation: null,
scale: null,
speed: 2.2,
rotationIntensity: 0.6,
floatIntensity: 0.8,
enabled: true,
},
children: ["accent3-body"],
},
"accent3-body": {
type: "GlassSphere",
props: {
position: null,
rotation: null,
scale: null,
castShadow: null,
receiveShadow: null,
radius: 0.12,
widthSegments: null,
heightSegments: null,
color: "#eeffee",
transmission: 1,
thickness: 0.25,
roughness: 0,
chromaticAberration: 0.05,
ior: 1.4,
distortion: null,
distortionScale: null,
temporalDistortion: null,
samples: 6,
resolution: 128,
},
children: [],
},
floor: {
type: "ReflectorPlane",
props: {
position: [0, -0.5, 0],
rotation: [-1.5708, 0, 0],
scale: null,
width: 25,
height: 25,
color: "#0a0a0a",
resolution: 1024,
blur: 300,
mirror: 0.6,
mixBlur: 8,
mixStrength: 2,
depthScale: 0.2,
metalness: 0.8,
roughness: 0.3,
},
children: [],
},
shadows: {
type: "ContactShadows",
props: {
position: [0, -0.49, 0],
rotation: null,
scale: null,
opacity: 0.5,
width: 10,
height: 10,
blur: 2,
near: null,
far: 5,
smooth: true,
resolution: 512,
frames: null,
color: "#000000",
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: [0, 2, 0],
rotation: null,
scale: [6, 4, 6],
count: 30,
speed: 0.05,
opacity: 0.15,
color: "#ffffff",
size: 0.3,
noise: 0.5,
},
children: [],
},
fx: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.5,
luminanceThreshold: 0.3,
luminanceSmoothing: 0.4,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.3, darkness: 0.5 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 3,
maxDistance: 12,
minPolarAngle: 0.3,
maxPolarAngle: 1.4,
autoRotate: true,
autoRotateSpeed: 1,
target: [0, 1.5, 0],
},
children: [],
},
},
},
};
@@ -0,0 +1,149 @@
import type { Scene } from "./_helpers";
export const starfield: Scene = {
name: "Starfield",
description:
"Classic Windows starfield screensaver -- flying through stars at warp speed",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"cam",
"ambient",
"tunnel",
"stars1",
"stars2",
"sparkle-dust",
"shake",
"post",
"controls",
],
},
cam: {
type: "PerspectiveCamera",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
fov: 75,
near: 0.1,
far: 1000,
makeDefault: true,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#111122", intensity: 0.15 },
children: [],
},
tunnel: {
type: "WarpTunnel",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
ringCount: 120,
radius: 4,
length: 40,
speed: 12,
tubeRadius: 0.008,
color1: "#ffffff",
color2: "#aabbff",
},
children: [],
},
stars1: {
type: "Stars",
props: {
radius: 50,
depth: 80,
count: 10000,
factor: 6,
saturation: 0,
fade: true,
speed: 3,
},
children: [],
},
stars2: {
type: "Stars",
props: {
radius: 30,
depth: 50,
count: 5000,
factor: 3,
saturation: 0.1,
fade: true,
speed: 5,
},
children: [],
},
"sparkle-dust": {
type: "Sparkles",
props: {
position: [0, 0, -5],
rotation: null,
scale: [8, 8, 30],
count: 300,
speed: 2,
opacity: 0.6,
color: "#ffffff",
size: 1,
noise: 4,
},
children: [],
},
shake: {
type: "CameraShake",
props: {
intensity: 0.15,
maxYaw: 0.02,
maxPitch: 0.02,
maxRoll: 0.01,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 1.5,
luminanceThreshold: 0.15,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.1, darkness: 0.9 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: null,
dampingFactor: null,
enableZoom: false,
enablePan: false,
enableRotate: false,
minDistance: null,
maxDistance: null,
minPolarAngle: null,
maxPolarAngle: null,
autoRotate: false,
autoRotateSpeed: null,
},
children: [],
},
},
},
};
@@ -0,0 +1,295 @@
import type { Scene } from "./_helpers";
export const stormCell: Scene = {
name: "Storm Cell",
description:
"Dark violent thunderstorm -- spinning cloud masses, pulsing lightning, rain particles, camera shake",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"sky",
"ambient",
"sun",
"cloud-spin-1",
"cloud-spin-2",
"cloud-spin-3",
"lightning-1-pulse",
"lightning-2-pulse",
"lightning-3-pulse",
"rain",
"shake",
"post",
"fog",
"controls",
],
},
sky: {
type: "Sky",
props: {
distance: null,
sunPosition: [10, 2, 0],
inclination: null,
azimuth: null,
turbidity: 20,
rayleigh: 0.5,
mieCoefficient: 0.01,
mieDirectionalG: 0.9,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#111122", intensity: 0.15 },
children: [],
},
sun: {
type: "DirectionalLight",
props: {
position: [10, 3, 0],
rotation: null,
scale: null,
color: "#334466",
intensity: 0.5,
castShadow: true,
},
children: [],
},
"cloud-spin-1": {
type: "Spin",
props: {
position: [0, 4, 0],
rotation: null,
scale: null,
speed: 0.08,
axis: "y",
},
children: ["cloud-1"],
},
"cloud-1": {
type: "Cloud",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
seed: 1,
segments: 40,
bounds: [25, 3, 25],
volume: 15,
speed: 0.3,
fade: 20,
opacity: 0.9,
color: "#222233",
growth: 10,
},
children: [],
},
"cloud-spin-2": {
type: "Spin",
props: {
position: [0, 6, -3],
rotation: null,
scale: null,
speed: -0.05,
axis: "y",
},
children: ["cloud-2"],
},
"cloud-2": {
type: "Cloud",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
seed: 42,
segments: 30,
bounds: [20, 2, 20],
volume: 12,
speed: 0.2,
fade: 15,
opacity: 0.7,
color: "#1a1a2a",
growth: 8,
},
children: [],
},
"cloud-spin-3": {
type: "Spin",
props: {
position: [0, 8, 2],
rotation: null,
scale: null,
speed: 0.03,
axis: "y",
},
children: ["cloud-3"],
},
"cloud-3": {
type: "Cloud",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
seed: 99,
segments: 25,
bounds: [30, 2, 30],
volume: 10,
speed: 0.15,
fade: 25,
opacity: 0.5,
color: "#2a2a3a",
growth: 6,
},
children: [],
},
"lightning-1-pulse": {
type: "Pulse",
props: {
position: [2, 5, -1],
rotation: null,
scale: null,
speed: 3,
min: 0,
max: 1,
},
children: ["lightning-1"],
},
"lightning-1": {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#aabbff",
intensity: 80,
distance: 30,
decay: 2,
castShadow: false,
},
children: [],
},
"lightning-2-pulse": {
type: "Pulse",
props: {
position: [-3, 6, 3],
rotation: null,
scale: null,
speed: 4.5,
min: 0,
max: 1,
},
children: ["lightning-2"],
},
"lightning-2": {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#aaccff",
intensity: 60,
distance: 25,
decay: 2,
castShadow: false,
},
children: [],
},
"lightning-3-pulse": {
type: "Pulse",
props: {
position: [0, 4, -4],
rotation: null,
scale: null,
speed: 2.5,
min: 0,
max: 1,
},
children: ["lightning-3"],
},
"lightning-3": {
type: "PointLight",
props: {
position: [0, 0, 0],
rotation: null,
scale: null,
color: "#99bbff",
intensity: 40,
distance: 20,
decay: 2,
castShadow: false,
},
children: [],
},
rain: {
type: "Sparkles",
props: {
position: [0, 5, 0],
rotation: null,
scale: [20, 15, 20],
count: 500,
speed: 5,
opacity: 0.4,
color: "#6688aa",
size: 0.5,
noise: 0.5,
},
children: [],
},
shake: {
type: "CameraShake",
props: {
intensity: 0.2,
maxYaw: 0.02,
maxPitch: 0.03,
maxRoll: 0.01,
},
children: [],
},
post: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.8,
luminanceThreshold: 0.3,
luminanceSmoothing: null,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.3, darkness: 0.8 },
children: [],
},
fog: {
type: "Fog",
props: { color: "#0a0a15", near: 5, far: 30 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: null,
dampingFactor: null,
enableZoom: true,
enablePan: null,
enableRotate: true,
minDistance: 3,
maxDistance: 20,
minPolarAngle: 0.2,
maxPolarAngle: 1.3,
autoRotate: true,
autoRotateSpeed: 0.1,
},
children: [],
},
},
},
};
@@ -0,0 +1,12 @@
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
"react/no-unknown-property": "off",
},
},
];
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@json-render/core",
"@json-render/react",
"@json-render/react-three-fiber",
"three",
],
};
export default nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "example-react-three-fiber",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless react-three-fiber-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/react-three-fiber": "workspace:*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"three": "^0.183.2",
"zod": "4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"@types/three": "^0.183.1",
"eslint": "^9.39.1",
"typescript": "^5.7.2"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>json-render solid example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "example-solid",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "portless solid-demo.json-render vite",
"build": "vite build",
"preview": "vite preview",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/solid": "workspace:*",
"solid-js": "^1.9.11",
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { StateProvider } from "@json-render/solid";
import { demoSpec } from "./lib/spec";
import { DemoRenderer } from "./DemoRenderer";
export function App() {
const initialState = demoSpec.state ?? {};
return (
<StateProvider initialState={initialState}>
<DemoRenderer />
</StateProvider>
);
}
+51
View File
@@ -0,0 +1,51 @@
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
useStateStore,
} from "@json-render/solid";
import { demoSpec } from "./lib/spec";
import { registry } from "./lib/registry";
export function DemoRenderer() {
const stateStore = useStateStore();
const handlers = {
increment: async () => {
stateStore.set("/count", Number(stateStore.get("/count") || 0) + 1);
},
decrement: async () => {
stateStore.set(
"/count",
Math.max(0, Number(stateStore.get("/count") || 0) - 1),
);
},
reset: async () => {
stateStore.set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
stateStore.get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).map((item, i) =>
i === index ? { ...item, completed: !item.completed } : item,
);
stateStore.set("/todos", todos);
},
};
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={demoSpec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
* {
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;
}
+5
View File
@@ -0,0 +1,5 @@
import { render } from "solid-js/web";
import { App } from "./App";
import "./app.css";
render(() => <App />, document.getElementById("app")!);
+90
View File
@@ -0,0 +1,90 @@
import { schema } from "@json-render/solid/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",
},
},
});
@@ -0,0 +1,27 @@
import type { BaseComponentProps } from "@json-render/solid";
interface BadgeProps {
label: string;
color?: string;
}
export function Badge(renderProps: BaseComponentProps<BadgeProps>) {
const color = () => renderProps.props.color ?? "#6b7280";
return (
<span
style={{
display: "inline-block",
padding: "4px 12px",
"border-radius": "9999px",
"font-size": "13px",
"font-weight": "500",
"background-color": `${color()}20`,
color: color(),
border: `1px solid ${color()}40`,
}}
>
{renderProps.props.label}
</span>
);
}
@@ -0,0 +1,47 @@
import type { BaseComponentProps } from "@json-render/solid";
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
const variantStyles: Record<string, Record<string, string>> = {
primary: {
background: "#2563eb",
color: "#ffffff",
border: "1px solid #2563eb",
},
secondary: {
background: "#f3f4f6",
color: "#374151",
border: "1px solid #d1d5db",
},
danger: {
background: "#fee2e2",
color: "#dc2626",
border: "1px solid #fca5a5",
},
};
export function Button(renderProps: BaseComponentProps<ButtonProps>) {
const variant = () => renderProps.props.variant ?? "primary";
return (
<button
disabled={renderProps.props.disabled}
onClick={() => renderProps.emit("press")}
style={{
padding: "8px 16px",
"border-radius": "8px",
"font-size": "14px",
"font-weight": "500",
cursor: renderProps.props.disabled ? "not-allowed" : "pointer",
opacity: renderProps.props.disabled ? "0.5" : "1",
...variantStyles[variant()],
}}
>
{renderProps.props.label}
</button>
);
}
@@ -0,0 +1,44 @@
import type { BaseComponentProps } from "@json-render/solid";
interface CardProps {
title?: string;
subtitle?: string;
}
export function Card(renderProps: BaseComponentProps<CardProps>) {
return (
<div
style={{
background: "#ffffff",
"border-radius": "12px",
border: "1px solid #e5e7eb",
padding: "20px",
"box-shadow": "0 1px 3px rgba(0,0,0,0.06)",
}}
>
{renderProps.props.title && (
<h2
style={{
"font-size": "18px",
"font-weight": "600",
"margin-bottom": renderProps.props.subtitle ? "4px" : "16px",
}}
>
{renderProps.props.title}
</h2>
)}
{renderProps.props.subtitle && (
<p
style={{
"font-size": "14px",
color: "#6b7280",
"margin-bottom": "16px",
}}
>
{renderProps.props.subtitle}
</p>
)}
{renderProps.children}
</div>
);
}
@@ -0,0 +1,30 @@
import type { BaseComponentProps } from "@json-render/solid";
import { useBoundProp } from "@json-render/solid";
interface InputProps {
value?: string;
placeholder?: string;
}
export function Input(renderProps: BaseComponentProps<InputProps>) {
const [value, setValue] = useBoundProp<string>(
renderProps.props.value,
renderProps.bindings?.value,
);
return (
<input
value={String(value ?? "")}
onInput={(e) => setValue(e.currentTarget.value)}
placeholder={renderProps.props.placeholder ?? ""}
style={{
padding: "8px 12px",
"border-radius": "8px",
border: "1px solid #d1d5db",
"font-size": "14px",
outline: "none",
width: "100%",
}}
/>
);
}
@@ -0,0 +1,55 @@
import type { BaseComponentProps } from "@json-render/solid";
interface ListItemProps {
title: string;
description?: string;
completed?: boolean;
}
export function ListItem(renderProps: BaseComponentProps<ListItemProps>) {
return (
<div
onClick={() => renderProps.emit("press")}
style={{
display: "flex",
"align-items": "center",
gap: "10px",
padding: "10px 12px",
"border-radius": "8px",
border: "1px solid #e5e7eb",
cursor: "pointer",
"user-select": "none",
background: renderProps.props.completed ? "#f0fdf4" : "#ffffff",
}}
>
<span
style={{
width: "22px",
height: "22px",
"border-radius": "6px",
border: renderProps.props.completed
? "2px solid #22c55e"
: "2px solid #d1d5db",
display: "flex",
"align-items": "center",
"justify-content": "center",
"font-size": "13px",
color: "#22c55e",
"flex-shrink": "0",
}}
>
{renderProps.props.completed ? "\u2713" : ""}
</span>
<span
style={{
"text-decoration": renderProps.props.completed
? "line-through"
: "none",
color: renderProps.props.completed ? "#9ca3af" : "#111827",
}}
>
{renderProps.props.title}
</span>
</div>
);
}
@@ -0,0 +1,27 @@
import type { BaseComponentProps } from "@json-render/solid";
interface StackProps {
gap?: number;
padding?: number;
direction?: "vertical" | "horizontal";
align?: "start" | "center" | "end";
}
export function Stack(renderProps: BaseComponentProps<StackProps>) {
return (
<div
style={{
display: "flex",
"flex-direction":
renderProps.props.direction === "horizontal" ? "row" : "column",
gap: renderProps.props.gap ? `${renderProps.props.gap}px` : undefined,
padding: renderProps.props.padding
? `${renderProps.props.padding}px`
: undefined,
"align-items": renderProps.props.align,
}}
>
{renderProps.children}
</div>
);
}
@@ -0,0 +1,35 @@
import type { BaseComponentProps } from "@json-render/solid";
interface TextProps {
content: string;
size?: "sm" | "md" | "lg" | "xl";
weight?: "normal" | "medium" | "bold";
color?: string;
}
const sizeMap: Record<string, string> = {
sm: "12px",
md: "14px",
lg: "18px",
xl: "24px",
};
const weightMap: Record<string, string> = {
normal: "400",
medium: "500",
bold: "700",
};
export function Text(renderProps: BaseComponentProps<TextProps>) {
return (
<span
style={{
"font-size": sizeMap[renderProps.props.size ?? "md"],
"font-weight": weightMap[renderProps.props.weight ?? "normal"],
color: renderProps.props.color,
}}
>
{String(renderProps.props.content ?? "")}
</span>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { type Components, defineRegistry } from "@json-render/solid";
import { catalog } from "./catalog";
import { Stack } from "./components/Stack";
import { Card } from "./components/Card";
import { Text } from "./components/Text";
import { Button } from "./components/Button";
import { Badge } from "./components/Badge";
import { ListItem } from "./components/ListItem";
import { Input } from "./components/Input";
const components: Components<typeof catalog> = {
Stack,
Card,
Text,
Button,
Badge,
ListItem,
Input,
};
export const { registry, handlers: makeHandlers } = defineRegistry(catalog, {
components,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
},
});
+130
View File
@@ -0,0 +1,130 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
count: 0,
name: "",
todos: [
{ id: 1, title: "Learn SolidJS", completed: true },
{ id: 2, title: "Try @json-render/solid", 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/solid demo",
size: "xl",
weight: "bold",
},
},
"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: "\u2212", 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": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
"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 } } },
},
},
"input-card": {
type: "Card",
props: {
title: "Bound Input",
subtitle: "Type to update state and see reactive text",
},
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",
},
},
},
};
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [solid()],
});
+4 -1
View File
@@ -12,10 +12,12 @@
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/solid": "workspace:*",
"@json-render/svelte": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.49.2",
"vue": "^3.5.29",
"zod": "4.3.6"
@@ -27,6 +29,7 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react" | "svelte";
type Renderer = "vue" | "react" | "svelte" | "solid";
const container = document.getElementById("renderer-root") as HTMLElement;
@@ -18,10 +18,14 @@ async function switchTo(renderer: Renderer) {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
} else if (renderer === "svelte") {
const mod = await import("./svelte/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./solid/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
+14 -1
View File
@@ -123,7 +123,9 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte"}
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
),
@@ -164,6 +166,17 @@ export const components: Components<AppCatalog> = {
>
Svelte
</button>
<button
onClick={() => emit("pressSolid")}
className={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
@@ -65,7 +65,7 @@ export const catalogDef = {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue, React, and Svelte renderers",
"Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
@@ -99,5 +99,9 @@ export const catalogDef = {
params: z.object({}),
description: "Switch to the Svelte renderer",
},
switchToSolid: {
params: z.object({}),
description: "Switch to the Solid renderer",
},
},
};
@@ -10,6 +10,7 @@ export const actionStubs = {
switchToVue: async () => {},
switchToReact: async () => {},
switchToSvelte: async () => {},
switchToSolid: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
@@ -52,5 +53,10 @@ export function makeHandlers(get: Get, set: Set) {
new CustomEvent("switch-renderer", { detail: "svelte" }),
);
},
switchToSolid: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "solid" }),
);
},
};
}
+40 -9
View File
@@ -11,9 +11,15 @@
align-items: center;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
.json-render-stack--align-start {
align-items: flex-start;
}
.json-render-stack--align-center {
align-items: center;
}
.json-render-stack--align-end {
align-items: flex-end;
}
/* ---- Card ----------------------------------------------------------------- */
@@ -50,12 +56,22 @@
color: #111827;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--sm {
font-size: 12px;
}
.json-render-text--lg {
font-size: 16px;
}
.json-render-text--xl {
font-size: 24px;
}
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
.json-render-text--medium {
font-weight: 500;
}
.json-render-text--bold {
font-weight: 700;
}
/* ---- Button --------------------------------------------------------------- */
@@ -211,7 +227,7 @@
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
.json-render-renderer-tab:not(:last-child) {
border-right: 1px solid #e5e7eb;
}
@@ -261,3 +277,18 @@
background-color: #ff3e00;
color: white;
}
.renderer-solid .json-render-renderer-badge {
color: #2c4f7c;
background-color: #2c4f7c18;
border-color: #2c4f7c40;
}
.renderer-solid .json-render-renderer-dot {
background-color: #2c4f7c;
}
.renderer-solid .json-render-renderer-tab--active {
background-color: #2c4f7c;
color: white;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/solid";
import { DemoRenderer } from "./DemoRenderer";
export function App(props: { initialRenderer?: string; spec: Spec }) {
const renderer = props.initialRenderer ?? "solid";
const initialState = {
...props.spec.state,
renderer,
};
return (
<div class={`renderer-${renderer}`}>
<StateProvider initialState={initialState}>
<DemoRenderer spec={props.spec} />
</StateProvider>
</div>
);
}
@@ -0,0 +1,32 @@
import type { Spec } from "@json-render/core";
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/solid";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
export function DemoRenderer(props: { spec: Spec }) {
const stateStore = useStateStore();
const handlers = makeHandlers(stateStore.get, stateStore.set);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={props.spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/solid/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,17 @@
import { render } from "solid-js/web";
import type { Spec } from "@json-render/core";
import { App } from "./App";
let dispose: (() => void) | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
dispose = render(
() => <App initialRenderer={renderer} spec={spec} />,
container,
);
}
export function unmount() {
dispose?.();
dispose = null;
}
@@ -0,0 +1,194 @@
import type { Components } from "@json-render/solid";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: (renderProps) => (
<div
class={[
"json-render-stack",
renderProps.props.direction === "horizontal" &&
"json-render-stack--horizontal",
renderProps.props.align &&
`json-render-stack--align-${renderProps.props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: renderProps.props.gap ? `${renderProps.props.gap}px` : undefined,
padding: renderProps.props.padding
? `${renderProps.props.padding}px`
: undefined,
}}
>
{renderProps.children}
</div>
),
Card: (renderProps) => (
<div class="json-render-card">
{renderProps.props.title && (
<div class="json-render-card-title-wrap">
<h2 class="json-render-card-title">{renderProps.props.title}</h2>
</div>
)}
{renderProps.props.subtitle && (
<p class="json-render-card-subtitle">{renderProps.props.subtitle}</p>
)}
{renderProps.children}
</div>
),
Text: (renderProps) => (
<span
class={[
"json-render-text",
renderProps.props.size !== "md" &&
renderProps.props.size &&
`json-render-text--${renderProps.props.size}`,
renderProps.props.weight !== "normal" &&
renderProps.props.weight &&
`json-render-text--${renderProps.props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={renderProps.props.color ? { color: renderProps.props.color } : {}}
>
{String(renderProps.props.content ?? "")}
</span>
),
Button: (renderProps) => (
<button
disabled={renderProps.props.disabled}
onClick={() => renderProps.emit("press")}
class={[
"json-render-button",
renderProps.props.variant &&
`json-render-button--${renderProps.props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.label}
</button>
),
Badge: (renderProps) => (
<span
class="json-render-badge"
style={
renderProps.props.color
? {
"background-color": `${renderProps.props.color}20`,
color: renderProps.props.color,
"border-color": `${renderProps.props.color}40`,
}
: {}
}
>
{renderProps.props.label}
</span>
),
ListItem: (renderProps) => (
<div
onClick={() => renderProps.emit("press")}
class={[
"json-render-list-item",
renderProps.props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
class={[
"json-render-list-item-check",
renderProps.props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.completed ? "✓" : ""}
</div>
<span
class={[
"json-render-list-item-text",
renderProps.props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.title}
</span>
</div>
),
RendererBadge: (renderProps) => (
<span class="json-render-renderer-badge">
<span class="json-render-renderer-dot" />
{renderProps.props.renderer === "vue"
? "Rendered with Vue"
: renderProps.props.renderer === "react"
? "Rendered with React"
: renderProps.props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
),
RendererTabs: (renderProps) => (
<div class="json-render-renderer-tabs-wrapper">
<span class="json-render-renderer-tabs-label">Render</span>
<div class="json-render-renderer-tabs">
<button
onClick={() => renderProps.emit("pressVue")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "vue" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => renderProps.emit("pressReact")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "react" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
<button
onClick={() => renderProps.emit("pressSvelte")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "svelte" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Svelte
</button>
<button
onClick={() => renderProps.emit("pressSolid")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "solid" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
};
+2 -1
View File
@@ -10,7 +10,7 @@ export const demoSpec: Spec = {
{
id: 2,
title:
"Try @json-render/vue, @json-render/react, and @json-render/svelte",
"Try @json-render/vue, @json-render/react, @json-render/svelte, and @json-render/solid",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
@@ -49,6 +49,7 @@ export const demoSpec: Spec = {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
pressSvelte: { action: "switchToSvelte" },
pressSolid: { action: "switchToSolid" },
},
},
@@ -12,5 +12,7 @@
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte"}
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
@@ -42,5 +42,16 @@
.join(" ")}>
Svelte
</button>
<button
type="button"
onclick={() => emit("pressSolid")}
class={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}>
Solid
</button>
</div>
</div>
+16 -1
View File
@@ -132,7 +132,9 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte",
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid",
]),
RendererTabs: ({ props, emit }) =>
@@ -178,6 +180,19 @@ export const components: Components<AppCatalog> = {
},
"Svelte",
),
h(
"button",
{
onClick: () => emit("pressSolid"),
class: [
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Solid",
),
]),
]),
};
+7 -1
View File
@@ -2,7 +2,13 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [svelte(), vue(), react({ include: /\.tsx$/ })],
plugins: [
svelte(),
vue(),
react({ include: /src\/react\/.*\.tsx$/ }),
solid({ include: [/src\/solid\/.*\.tsx$/, /packages\/solid\/.*\.tsx$/] }),
],
});
+11 -1
View File
@@ -32,6 +32,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@solidjs/testing-library": "^0.8.10",
"@testing-library/svelte": "^5.2.0",
"@types/react": "^19.2.3",
"husky": "^9.1.7",
@@ -40,9 +41,11 @@
"prettier": "^3.7.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.0.0",
"turbo": "^2.7.4",
"typescript": "5.9.2",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@10.29.3",
@@ -51,5 +54,12 @@
},
"lint-staged": {
"*.{ts,tsx}": "prettier --write"
}
},
"workspaces": [
"apps/*",
"examples/*",
"examples/stripe-app/*",
"packages/*",
"tests/*"
]
}
+1
View File
@@ -160,6 +160,7 @@ export { defineSchema } from "./schema";
// Catalog — defines the vocabulary (what components and actions are available)
export type {
Catalog,
JsonSchemaOptions,
PromptOptions,
PromptContext,
SpecValidationResult,
+188
View File
@@ -592,6 +592,194 @@ describe("catalog.jsonSchema", () => {
expect(jsonSchema).not.toBeNull();
expect(typeof jsonSchema).toBe("object");
});
describe("strict mode (LLM structured output compatible)", () => {
function hasNoPropertyNames(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
if ("propertyNames" in obj) return false;
return Object.values(obj).every(hasNoPropertyNames);
}
function allObjectsHaveAdditionalPropertiesFalse(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const record = obj as Record<string, unknown>;
if (record.type === "object") {
if (record.additionalProperties !== false) return false;
}
return Object.values(record).every(
allObjectsHaveAdditionalPropertiesFalse,
);
}
function allObjectPropertiesRequired(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const record = obj as Record<string, unknown>;
if (
record.type === "object" &&
record.properties &&
typeof record.properties === "object"
) {
const propKeys = Object.keys(record.properties);
const required = (record.required as string[]) ?? [];
if (!propKeys.every((k) => required.includes(k))) return false;
}
return Object.values(record).every(allObjectPropertiesRequired);
}
it("sets additionalProperties: false on all nested objects", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(allObjectsHaveAdditionalPropertiesFalse(schema)).toBe(true);
});
it("does not emit propertyNames", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(hasNoPropertyNames(schema)).toBe(true);
});
it("lists all properties in required (optional uses nullable)", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(allObjectPropertiesRequired(schema)).toBe(true);
});
it("converts record types without additionalProperties schema value", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({
strict: true,
}) as Record<string, unknown>;
// Walk the schema and ensure no additionalProperties is set to a non-false value
function noAdditionalPropertiesSchema(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const rec = obj as Record<string, unknown>;
if (
"additionalProperties" in rec &&
rec.additionalProperties !== false
) {
return false;
}
return Object.values(rec).every(noAdditionalPropertiesSchema);
}
expect(noAdditionalPropertiesSchema(schema)).toBe(true);
});
it("wraps optional properties with anyOf nullable", () => {
// Use a schema where propsOf resolves to a single component's props
// (no record wrapper around the props) so the optional anyOf handling
// is directly visible in the JSON Schema output.
const flatSchema = defineSchema((s) => ({
spec: s.object({
component: s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
}),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));
const catalog = defineCatalog(flatSchema, {
components: {
Card: {
props: z.object({
heading: z.string(),
caption: z.string().optional(),
}),
description: "",
},
},
});
const schema = catalog.jsonSchema({ strict: true }) as {
properties: {
component: {
properties: { props: Record<string, unknown> };
};
};
};
const propsSchema = schema.properties.component.properties.props;
// caption is optional – in strict mode it must be in `required`
// and wrapped in anyOf with null
const captionSchema = (
propsSchema as {
properties: { caption: Record<string, unknown> };
}
).properties.caption;
expect(captionSchema).toEqual({
anyOf: [{ type: "string" }, { type: "null" }],
});
const propsRequired = (propsSchema as { required: string[] }).required;
expect(propsRequired).toContain("heading");
expect(propsRequired).toContain("caption");
});
it("does not affect default (non-strict) output", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const defaultSchema = catalog.jsonSchema();
const defaultSchema2 = catalog.jsonSchema({ strict: false });
expect(defaultSchema).toEqual(defaultSchema2);
});
});
});
// =============================================================================
+147 -34
View File
@@ -92,7 +92,7 @@ export interface Catalog<
/** Generate system prompt for AI */
prompt(options?: PromptOptions): string;
/** Export as JSON Schema for structured outputs */
jsonSchema(): object;
jsonSchema(options?: JsonSchemaOptions): object;
/** Validate a spec against this catalog */
validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>>;
/** Get the Zod schema for the spec */
@@ -101,6 +101,28 @@ export interface Catalog<
readonly _specType: InferSpec<TDef, TCatalog>;
}
/**
* Options for JSON Schema export
*/
export interface JsonSchemaOptions {
/**
* When true, produces a strict JSON Schema subset compatible with
* LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).
* This ensures:
* - `additionalProperties: false` on every object
* - All object properties listed in `required` (optionals use nullable types)
* - Record/map types converted to fixed-key objects
*
* **Limitation:** Record types (dynamic-key maps) cannot be represented in
* strict JSON Schema because `additionalProperties` must be `false`. They
* are emitted as `{ type: "object", properties: {}, additionalProperties: false }`.
* The LLM prompt (via `catalog.prompt()`) still describes the full structure,
* so the model can produce valid output even though the JSON Schema for
* record entries is opaque.
*/
strict?: boolean;
}
/**
* Prompt generation options
*/
@@ -414,8 +436,8 @@ function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
return generatePrompt(this, options);
},
jsonSchema(): object {
return zodToJsonSchema(zodSchema);
jsonSchema(options: JsonSchemaOptions = {}): object {
return zodToJsonSchema(zodSchema, options.strict ?? false);
},
validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
@@ -1319,44 +1341,111 @@ function formatZodType(schema: z.ZodType): string {
}
/**
* Convert Zod schema to JSON Schema
* Resolve the Zod type name from a schema's internal definition.
* Supports both Zod 3 (`_def.typeName`) and Zod 4 (`_def.type`).
*/
function zodToJsonSchema(schema: z.ZodType): object {
// Simplified JSON Schema conversion
const def = schema._def as unknown as Record<string, unknown>;
const typeName = (def.typeName as string) ?? "";
function zodTypeName(def: Record<string, unknown>): string {
// Zod 4 uses _def.type as a plain string (e.g. "string", "object")
if (typeof def.type === "string") return def.type;
// Zod 3 uses _def.typeName (e.g. "ZodString", "ZodObject")
if (typeof def.typeName === "string") return def.typeName;
return "";
}
switch (typeName) {
case "ZodString":
/**
* Normalise a Zod type name to a canonical lowercase form.
* Handles both Zod 3 ("ZodString") and Zod 4 ("string") conventions.
*/
function normalizeTypeName(raw: string): string {
// Zod 3 names start with "Zod", e.g. "ZodString" → "string"
if (raw.startsWith("Zod")) {
return raw.slice(3).toLowerCase();
}
return raw.toLowerCase();
}
/**
* Convert Zod schema to JSON Schema.
*
* When `strict` is true the output conforms to the JSON Schema subset required
* by LLM structured output APIs (no `propertyNames`, `additionalProperties: false`
* everywhere, all properties listed in `required`).
*/
function zodToJsonSchema(schema: z.ZodType, strict = false): object {
const def = schema._def as unknown as Record<string, unknown>;
const kind = normalizeTypeName(zodTypeName(def));
switch (kind) {
case "string":
return { type: "string" };
case "ZodNumber":
case "number":
return { type: "number" };
case "ZodBoolean":
case "boolean":
return { type: "boolean" };
case "ZodLiteral":
return { const: def.value };
case "ZodEnum":
return { enum: def.values };
case "ZodArray": {
const inner = def.type as z.ZodType | undefined;
case "literal": {
// Zod 4: _def.values (array), Zod 3: _def.value (single)
const values = def.values as unknown[] | undefined;
const value = values ? values[0] : def.value;
return { const: value };
}
case "enum": {
// Zod 4: _def.entries (object { a:"a", b:"b" }), Zod 3: _def.values (string[])
const entries = def.entries as Record<string, string> | undefined;
const values = entries
? Object.values(entries)
: (def.values as string[] | undefined);
return { enum: values ?? [] };
}
case "array": {
// Zod 4: _def.element, Zod 3: _def.type
const inner = (def.element ?? def.type) as z.ZodType | undefined;
return {
type: "array",
items: inner ? zodToJsonSchema(inner) : {},
items: inner ? zodToJsonSchema(inner, strict) : {},
};
}
case "ZodObject": {
const shape = (def.shape as () => Record<string, z.ZodType>)?.();
if (!shape) return { type: "object" };
case "object": {
// Zod 4: _def.shape is an object, Zod 3: _def.shape is a function
const rawShape = def.shape;
const shape: Record<string, z.ZodType> | undefined =
typeof rawShape === "function"
? (rawShape as () => Record<string, z.ZodType>)()
: (rawShape as Record<string, z.ZodType> | undefined);
if (!shape) {
if (strict) {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return { type: "object" };
}
const properties: Record<string, object> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
properties[key] = zodToJsonSchema(value);
const innerDef = value._def as unknown as Record<string, unknown>;
if (
innerDef.typeName !== "ZodOptional" &&
innerDef.typeName !== "ZodNullable"
) {
const innerKind = normalizeTypeName(zodTypeName(innerDef));
const isOptional = innerKind === "optional" || innerKind === "nullable";
if (strict) {
// In strict mode, all properties must be in required.
// Optional properties are represented as nullable types.
required.push(key);
if (isOptional) {
const unwrapped = zodToJsonSchema(value, strict);
properties[key] = { anyOf: [unwrapped, { type: "null" }] };
} else {
properties[key] = zodToJsonSchema(value, strict);
}
} else {
properties[key] = zodToJsonSchema(value);
if (!isOptional) {
required.push(key);
}
}
}
return {
@@ -1366,23 +1455,47 @@ function zodToJsonSchema(schema: z.ZodType): object {
additionalProperties: false,
};
}
case "ZodRecord": {
case "record": {
const valueType = def.valueType as z.ZodType | undefined;
if (strict) {
// LLM strict schemas require `additionalProperties: false` and do not
// permit a schema value for `additionalProperties`. Since record types
// have dynamic keys that cannot be enumerated at schema-generation time,
// we emit an opaque object. The LLM prompt still describes the expected
// structure so the model can produce valid output.
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return {
type: "object",
additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
};
}
case "ZodOptional":
case "ZodNullable": {
case "optional":
case "nullable": {
const inner = def.innerType as z.ZodType | undefined;
return inner ? zodToJsonSchema(inner) : {};
return inner ? zodToJsonSchema(inner, strict) : {};
}
case "ZodUnion": {
case "union": {
const options = def.options as z.ZodType[] | undefined;
return options ? { anyOf: options.map(zodToJsonSchema) } : {};
return options
? { anyOf: options.map((o) => zodToJsonSchema(o, strict)) }
: {};
}
case "ZodAny":
case "any":
case "unknown":
if (strict) {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return {};
default:
return {};
+75
View File
@@ -0,0 +1,75 @@
# @json-render/react-three-fiber
React Three Fiber renderer for [`@json-render/core`](https://json-render.dev). JSON becomes 3D scenes.
19 built-in components for meshes, lights, models, environments, text, cameras, and controls.
## Installation
```bash
npm install @json-render/react-three-fiber @json-render/core @json-render/react @react-three/fiber @react-three/drei three zod
```
## Usage
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
// Build catalog with 3D components
const catalog = defineCatalog(schema, {
components: {
Box: threeComponentDefinitions.Box,
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
OrbitControls: threeComponentDefinitions.OrbitControls,
},
actions: {},
});
// Register implementations
const { registry } = defineRegistry(catalog, {
components: {
Box: threeComponents.Box,
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
OrbitControls: threeComponents.OrbitControls,
},
});
// Render
<ThreeCanvas
spec={spec}
registry={registry}
shadows
camera={{ position: [5, 5, 5], fov: 50 }}
style={{ width: "100%", height: "100vh" }}
/>;
```
## Components
**Primitives:** Box, Sphere, Cylinder, Cone, Torus, Plane, Capsule
**Lights:** AmbientLight, DirectionalLight, PointLight, SpotLight
**Container:** Group
**Model:** Model (GLTF/GLB)
**Environment:** Environment, Fog, GridHelper
**Text:** Text3D
**Camera/Controls:** PerspectiveCamera, OrbitControls
## Docs
Full API reference: [json-render.dev/docs/api/three](https://json-render.dev/docs/api/three)
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@json-render/react-three-fiber",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "React Three Fiber renderer for @json-render/core. JSON becomes 3D scenes.",
"keywords": [
"json",
"3d",
"three",
"threejs",
"react-three-fiber",
"r3f",
"webgl",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-three-fiber"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@react-three/postprocessing": "^3.0.4"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@types/react": "19.2.3",
"@types/three": "^0.183.1",
"three": "^0.183.2",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"@react-three/drei": ">=9.0.0",
"@react-three/fiber": ">=8.0.0",
"react": "^19.0.0",
"three": ">=0.160.0",
"zod": "^4.0.0"
}
}
+841
View File
@@ -0,0 +1,841 @@
import { z } from "zod";
import {
vector3Schema,
materialSchema,
transformProps,
shadowProps,
} from "./schemas";
/**
* React Three Fiber component definitions for json-render catalogs.
*
* These can be used directly or extended with custom 3D components.
* All components render as React Three Fiber elements.
*/
export const threeComponentDefinitions = {
// ===========================================================================
// Primitives
// ===========================================================================
Box: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
width: z.number().nullable(),
height: z.number().nullable(),
depth: z.number().nullable(),
}),
description:
"Box mesh. Defaults to 1x1x1. Use material for appearance, transform props for placement.",
example: {
position: [0, 0.5, 0],
material: { color: "#4488ff" },
},
},
Sphere: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radius: z.number().nullable(),
widthSegments: z.number().nullable(),
heightSegments: z.number().nullable(),
}),
description: "Sphere mesh. Defaults to radius 1.",
example: {
position: [0, 1, 0],
radius: 0.5,
material: { color: "#ff4444" },
},
},
Cylinder: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radiusTop: z.number().nullable(),
radiusBottom: z.number().nullable(),
height: z.number().nullable(),
radialSegments: z.number().nullable(),
}),
description: "Cylinder mesh. Use radiusTop/radiusBottom for tapering.",
example: {
position: [0, 0.5, 0],
height: 1,
material: { color: "#44ff44" },
},
},
Cone: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radius: z.number().nullable(),
height: z.number().nullable(),
radialSegments: z.number().nullable(),
}),
description: "Cone mesh.",
example: {
position: [0, 0.5, 0],
material: { color: "#ffaa00" },
},
},
Torus: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radius: z.number().nullable(),
tube: z.number().nullable(),
radialSegments: z.number().nullable(),
tubularSegments: z.number().nullable(),
}),
description: "Torus (donut) mesh.",
example: {
position: [0, 1, 0],
material: { color: "#ff44ff" },
},
},
Plane: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
width: z.number().nullable(),
height: z.number().nullable(),
}),
slots: ["default"],
description:
"Flat plane mesh. Useful for floors/walls. Defaults to XY plane; rotate [-Math.PI/2, 0, 0] for ground. Pass MeshPortalMaterial as child for portal surfaces.",
example: {
position: [0, 0, 0],
rotation: [-1.5708, 0, 0],
scale: [10, 10, 1],
material: { color: "#888888" },
receiveShadow: true,
},
},
Capsule: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radius: z.number().nullable(),
length: z.number().nullable(),
capSegments: z.number().nullable(),
radialSegments: z.number().nullable(),
}),
description: "Capsule mesh (cylinder with hemispherical caps).",
example: {
position: [0, 1, 0],
material: { color: "#00cccc" },
},
},
// ===========================================================================
// Lights
// ===========================================================================
AmbientLight: {
props: z.object({
color: z.string().nullable(),
intensity: z.number().nullable(),
}),
description:
"Ambient light that illuminates all objects equally. No position needed.",
example: { color: "#ffffff", intensity: 0.5 },
},
DirectionalLight: {
props: z.object({
...transformProps,
color: z.string().nullable(),
intensity: z.number().nullable(),
castShadow: z.boolean().nullable(),
}),
description:
"Directional light (like sunlight). Position determines the direction it shines from.",
example: {
position: [5, 10, 5],
intensity: 1,
castShadow: true,
},
},
PointLight: {
props: z.object({
...transformProps,
color: z.string().nullable(),
intensity: z.number().nullable(),
distance: z.number().nullable(),
decay: z.number().nullable(),
castShadow: z.boolean().nullable(),
}),
description: "Point light that radiates in all directions from a position.",
example: {
position: [0, 3, 0],
intensity: 1,
distance: 10,
},
},
SpotLight: {
props: z.object({
...transformProps,
color: z.string().nullable(),
intensity: z.number().nullable(),
distance: z.number().nullable(),
decay: z.number().nullable(),
angle: z.number().nullable(),
penumbra: z.number().nullable(),
castShadow: z.boolean().nullable(),
}),
description:
"Spot light emitting a cone of light. Angle in radians (default ~Math.PI/3).",
example: {
position: [0, 5, 0],
intensity: 1,
angle: 0.5,
penumbra: 0.5,
castShadow: true,
},
},
// ===========================================================================
// Container
// ===========================================================================
Group: {
props: z.object({
...transformProps,
}),
slots: ["default"],
description:
"Container for grouping child objects. Transforms apply to all children.",
example: { position: [0, 0, 0] },
},
// ===========================================================================
// Model
// ===========================================================================
Model: {
props: z.object({
...transformProps,
...shadowProps,
url: z.string(),
}),
description:
"GLTF/GLB 3D model loader. Provide a URL to a .glb or .gltf file.",
example: {
url: "/models/robot.glb",
position: [0, 0, 0],
scale: [1, 1, 1],
},
},
// ===========================================================================
// Environment
// ===========================================================================
Environment: {
props: z.object({
preset: z
.enum([
"apartment",
"city",
"dawn",
"forest",
"lobby",
"night",
"park",
"studio",
"sunset",
"warehouse",
])
.nullable(),
background: z.boolean().nullable(),
blur: z.number().nullable(),
intensity: z.number().nullable(),
}),
description:
"HDRI environment map for lighting and background. Choose a preset or provide a custom HDRI.",
example: { preset: "sunset", background: true },
},
Fog: {
props: z.object({
color: z.string().nullable(),
near: z.number().nullable(),
far: z.number().nullable(),
}),
description:
"Linear fog effect. Objects fade between near and far distances.",
example: { color: "#cccccc", near: 10, far: 50 },
},
GridHelper: {
props: z.object({
...transformProps,
size: z.number().nullable(),
divisions: z.number().nullable(),
color: z.string().nullable(),
secondaryColor: z.string().nullable(),
}),
description: "Visual grid for reference. Renders on the XZ plane.",
example: { size: 10, divisions: 10 },
},
// ===========================================================================
// Text
// ===========================================================================
Text3D: {
props: z.object({
...transformProps,
text: z.string(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
anchorX: z.enum(["left", "center", "right"]).nullable(),
anchorY: z
.enum(["top", "top-baseline", "middle", "bottom-baseline", "bottom"])
.nullable(),
maxWidth: z.number().nullable(),
}),
description:
"3D text rendered in the scene. Uses SDF text for crisp rendering at any size.",
example: {
text: "Hello World",
fontSize: 1,
color: "#ffffff",
position: [0, 2, 0],
},
},
// ===========================================================================
// Effects / Atmosphere
// ===========================================================================
Sparkles: {
props: z.object({
...transformProps,
count: z.number().nullable(),
speed: z.number().nullable(),
opacity: z.number().nullable(),
color: z.string().nullable(),
size: z.number().nullable(),
noise: z.number().nullable(),
}),
description:
"Floating particle sparkles. Great for magic, snow, or ambient effects.",
example: {
count: 100,
speed: 0.5,
size: 2,
color: "#ffffff",
scale: [5, 5, 5],
},
},
Stars: {
props: z.object({
radius: z.number().nullable(),
depth: z.number().nullable(),
count: z.number().nullable(),
factor: z.number().nullable(),
saturation: z.number().nullable(),
fade: z.boolean().nullable(),
speed: z.number().nullable(),
}),
description:
"Starfield background. Renders thousands of stars in a sphere around the scene.",
example: { radius: 100, depth: 50, count: 5000, factor: 4, fade: true },
},
Sky: {
props: z.object({
distance: z.number().nullable(),
sunPosition: vector3Schema.nullable(),
inclination: z.number().nullable(),
azimuth: z.number().nullable(),
mieCoefficient: z.number().nullable(),
mieDirectionalG: z.number().nullable(),
rayleigh: z.number().nullable(),
turbidity: z.number().nullable(),
}),
description:
"Procedural sky with sun. Control sun position, haze, and scattering.",
example: { sunPosition: [100, 20, 100], turbidity: 8, rayleigh: 2 },
},
Cloud: {
props: z.object({
...transformProps,
seed: z.number().nullable(),
segments: z.number().nullable(),
bounds: vector3Schema.nullable(),
volume: z.number().nullable(),
speed: z.number().nullable(),
fade: z.number().nullable(),
opacity: z.number().nullable(),
color: z.string().nullable(),
growth: z.number().nullable(),
}),
description:
"Volumetric cloud. Use multiple for a cloudscape. Wrap in a Clouds parent for batching.",
example: {
position: [0, 5, 0],
speed: 0.2,
opacity: 0.6,
color: "#ffffff",
},
},
// ===========================================================================
// Special Materials (geometry + material combos)
// ===========================================================================
GlassSphere: {
props: z.object({
...transformProps,
...shadowProps,
radius: z.number().nullable(),
widthSegments: z.number().nullable(),
heightSegments: z.number().nullable(),
color: z.string().nullable(),
transmission: z.number().nullable(),
thickness: z.number().nullable(),
roughness: z.number().nullable(),
chromaticAberration: z.number().nullable(),
ior: z.number().nullable(),
distortion: z.number().nullable(),
distortionScale: z.number().nullable(),
temporalDistortion: z.number().nullable(),
samples: z.number().nullable(),
resolution: z.number().nullable(),
}),
description:
"Glass sphere with transmission/refraction. Creates photorealistic glass effect.",
example: {
position: [0, 1, 0],
radius: 1,
transmission: 1,
thickness: 0.5,
roughness: 0,
chromaticAberration: 0.06,
},
},
GlassBox: {
props: z.object({
...transformProps,
...shadowProps,
width: z.number().nullable(),
height: z.number().nullable(),
depth: z.number().nullable(),
color: z.string().nullable(),
transmission: z.number().nullable(),
thickness: z.number().nullable(),
roughness: z.number().nullable(),
chromaticAberration: z.number().nullable(),
ior: z.number().nullable(),
distortion: z.number().nullable(),
distortionScale: z.number().nullable(),
temporalDistortion: z.number().nullable(),
samples: z.number().nullable(),
resolution: z.number().nullable(),
}),
description:
"Glass box with transmission/refraction. Photorealistic glass cuboid.",
example: {
position: [0, 0.5, 0],
width: 1,
height: 1,
depth: 1,
transmission: 1,
thickness: 0.5,
},
},
DistortSphere: {
props: z.object({
...transformProps,
...shadowProps,
radius: z.number().nullable(),
widthSegments: z.number().nullable(),
heightSegments: z.number().nullable(),
color: z.string().nullable(),
speed: z.number().nullable(),
distort: z.number().nullable(),
metalness: z.number().nullable(),
roughness: z.number().nullable(),
}),
description:
"Animated distorting sphere. Organic, blobby look -- like liquid metal.",
example: {
position: [0, 1, 0],
radius: 1,
color: "#ff6600",
speed: 2,
distort: 0.5,
},
},
// ===========================================================================
// Extended Geometry
// ===========================================================================
TorusKnot: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
radius: z.number().nullable(),
tube: z.number().nullable(),
tubularSegments: z.number().nullable(),
radialSegments: z.number().nullable(),
p: z.number().nullable(),
q: z.number().nullable(),
}),
description:
"Torus knot mesh. A continuous 3D curve that creates intricate knot shapes via p and q parameters.",
example: {
position: [0, 1, 0],
radius: 1,
tube: 0.3,
p: 2,
q: 3,
material: { color: "#ff44ff", metalness: 0.8, roughness: 0.2 },
},
},
RoundedBox: {
props: z.object({
...transformProps,
...shadowProps,
material: materialSchema.nullable(),
width: z.number().nullable(),
height: z.number().nullable(),
depth: z.number().nullable(),
radius: z.number().nullable(),
smoothness: z.number().nullable(),
}),
description:
"Box with rounded edges. More polished look than a plain box. Great for product-style scenes.",
example: {
position: [0, 0.5, 0],
width: 1,
height: 1,
depth: 1,
radius: 0.1,
material: { color: "#4488ff" },
},
},
// ===========================================================================
// Shadows / Staging
// ===========================================================================
ContactShadows: {
props: z.object({
...transformProps,
opacity: z.number().nullable(),
width: z.number().nullable(),
height: z.number().nullable(),
blur: z.number().nullable(),
near: z.number().nullable(),
far: z.number().nullable(),
smooth: z.boolean().nullable(),
resolution: z.number().nullable(),
frames: z.number().nullable(),
color: z.string().nullable(),
}),
description:
"Soft contact shadows projected onto the ground plane. Place at y=0 under objects.",
example: {
position: [0, 0, 0],
opacity: 0.5,
blur: 2,
width: 10,
height: 10,
},
},
Float: {
props: z.object({
...transformProps,
speed: z.number().nullable(),
rotationIntensity: z.number().nullable(),
floatIntensity: z.number().nullable(),
enabled: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Wrapper that makes children gently float and bob. Adds organic motion to any object.",
example: { speed: 1.5, rotationIntensity: 1, floatIntensity: 1 },
},
ReflectorPlane: {
props: z.object({
...transformProps,
width: z.number().nullable(),
height: z.number().nullable(),
color: z.string().nullable(),
resolution: z.number().nullable(),
blur: z.number().nullable(),
mirror: z.number().nullable(),
mixBlur: z.number().nullable(),
mixStrength: z.number().nullable(),
depthScale: z.number().nullable(),
metalness: z.number().nullable(),
roughness: z.number().nullable(),
}),
description:
"Reflective floor/surface. Real-time mirror reflections on a plane. Rotate [-PI/2,0,0] for ground.",
example: {
position: [0, 0, 0],
rotation: [-1.5708, 0, 0],
width: 20,
height: 20,
mirror: 0.5,
blur: [300, 100],
resolution: 1024,
},
},
Backdrop: {
props: z.object({
...transformProps,
floor: z.number().nullable(),
segments: z.number().nullable(),
receiveShadow: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Curved studio backdrop for product-style scenes. Set floor for how much bends.",
example: { floor: 0.25, segments: 20, receiveShadow: true },
},
// ===========================================================================
// Crazy / Magic
// ===========================================================================
MeshPortalMaterial: {
props: z.object({
blend: z.number().nullable(),
blur: z.number().nullable(),
resolution: z.number().nullable(),
}),
slots: ["default"],
description:
"Renders children into a portal surface (like a window to another world). Use as a child of a Mesh.",
example: { blend: 0 },
},
HtmlLabel: {
props: z.object({
...transformProps,
text: z.string(),
transform: z.boolean().nullable(),
distanceFactor: z.number().nullable(),
color: z.string().nullable(),
fontSize: z.number().nullable(),
center: z.boolean().nullable(),
}),
description: "Renders actual HTML/DOM text floating in the 3D scene.",
example: { text: "Hello World", transform: true, distanceFactor: 10 },
},
// ===========================================================================
// Post-Processing
// ===========================================================================
EffectComposer: {
props: z.object({
enabled: z.boolean().nullable(),
multisampling: z.number().nullable(),
}),
slots: ["default"],
description:
"Wrapper for post-processing effects. Add effects as children.",
example: { multisampling: 8 },
},
Bloom: {
props: z.object({
intensity: z.number().nullable(),
luminanceThreshold: z.number().nullable(),
luminanceSmoothing: z.number().nullable(),
mipmapBlur: z.boolean().nullable(),
}),
description: "Bloom post-processing effect. Makes emissive materials glow.",
example: { intensity: 1.5, luminanceThreshold: 0.1, mipmapBlur: true },
},
Glitch: {
props: z.object({
delay: z.tuple([z.number(), z.number()]).nullable(),
duration: z.tuple([z.number(), z.number()]).nullable(),
strength: z.tuple([z.number(), z.number()]).nullable(),
active: z.boolean().nullable(),
ratio: z.number().nullable(),
}),
description: "Cyberpunk glitch post-processing effect.",
example: { active: true },
},
Vignette: {
props: z.object({
offset: z.number().nullable(),
darkness: z.number().nullable(),
}),
description: "Vignette post-processing effect (darkened corners).",
example: { offset: 0.5, darkness: 0.5 },
},
// ===========================================================================
// Animation Wrappers
// ===========================================================================
WarpTunnel: {
props: z.object({
...transformProps,
ringCount: z.number().nullable(),
radius: z.number().nullable(),
length: z.number().nullable(),
speed: z.number().nullable(),
tubeRadius: z.number().nullable(),
color1: z.string().nullable(),
color2: z.string().nullable(),
}),
description:
"Animated neon tunnel flythrough. Rings rush toward the camera creating a hyperspace warp effect.",
example: {
speed: 8,
ringCount: 80,
radius: 3,
color1: "#00ffff",
color2: "#ff00ff",
},
},
Spin: {
props: z.object({
...transformProps,
speed: z.number().nullable(),
axis: z.enum(["x", "y", "z"]).nullable(),
}),
slots: ["default"],
description:
"Wrapper that continuously spins children around an axis. Speed in radians/second.",
example: { speed: 1, axis: "y" },
},
Orbit: {
props: z.object({
...transformProps,
speed: z.number().nullable(),
radius: z.number().nullable(),
tilt: z.number().nullable(),
}),
slots: ["default"],
description:
"Wrapper that orbits children around the Y axis at a given radius and speed.",
example: { speed: 1, radius: 3, tilt: 0.5 },
},
Pulse: {
props: z.object({
...transformProps,
speed: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
}),
slots: ["default"],
description:
"Wrapper that pulses children's scale between min and max values.",
example: { speed: 1, min: 0.8, max: 1.2 },
},
CameraShake: {
props: z.object({
intensity: z.number().nullable(),
maxYaw: z.number().nullable(),
maxPitch: z.number().nullable(),
maxRoll: z.number().nullable(),
}),
description:
"Adds camera shake/vibration. Great for speed, impact, or unease effects.",
example: { intensity: 0.5, maxYaw: 0.1, maxPitch: 0.1, maxRoll: 0.1 },
},
// ===========================================================================
// Camera / Controls
// ===========================================================================
PerspectiveCamera: {
props: z.object({
...transformProps,
fov: z.number().nullable(),
near: z.number().nullable(),
far: z.number().nullable(),
makeDefault: z.boolean().nullable(),
}),
description:
"Perspective camera. Set makeDefault to use as the scene's main camera.",
example: {
position: [5, 5, 5],
fov: 50,
makeDefault: true,
},
},
OrbitControls: {
props: z.object({
enableDamping: z.boolean().nullable(),
dampingFactor: z.number().nullable(),
enableZoom: z.boolean().nullable(),
enablePan: z.boolean().nullable(),
enableRotate: z.boolean().nullable(),
minDistance: z.number().nullable(),
maxDistance: z.number().nullable(),
minPolarAngle: z.number().nullable(),
maxPolarAngle: z.number().nullable(),
autoRotate: z.boolean().nullable(),
autoRotateSpeed: z.number().nullable(),
target: z.tuple([z.number(), z.number(), z.number()]).nullable(),
}),
description:
"Orbit camera controls. Allows the user to rotate, zoom, and pan around the scene.",
example: { enableDamping: true, autoRotate: false },
},
};
// =============================================================================
// Types
// =============================================================================
export type ComponentDefinition = {
props: z.ZodType;
slots?: string[];
description: string;
example?: Record<string, unknown>;
};
/**
* Infer the props type for a Three component by name.
*
* @example
* ```ts
* type BoxProps = ThreeProps<"Box">;
* ```
*/
export type ThreeProps<K extends keyof typeof threeComponentDefinitions> =
z.output<(typeof threeComponentDefinitions)[K]["props"]>;
@@ -0,0 +1,909 @@
// @ts-nocheck
import React, { type ReactNode, Suspense, useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import {
Environment as DreiEnvironment,
OrbitControls as DreiOrbitControls,
PerspectiveCamera as DreiPerspectiveCamera,
Text as DreiText,
Gltf,
Sparkles as DreiSparkles,
Stars as DreiStars,
Sky as DreiSky,
Cloud as DreiCloud,
Clouds as DreiClouds,
Float as DreiFloat,
ContactShadows as DreiContactShadows,
MeshTransmissionMaterial,
MeshDistortMaterial,
MeshReflectorMaterial,
MeshPortalMaterial as DreiMeshPortalMaterial,
Html as DreiHtml,
RoundedBox as DreiRoundedBox,
Backdrop as DreiBackdrop,
CameraShake as DreiCameraShake,
} from "@react-three/drei";
// @ts-ignore
import * as postprocessing from "@react-three/postprocessing";
import * as THREE from "three";
import type { BaseComponentProps } from "@json-render/react";
import type { ThreeProps } from "./catalog";
// =============================================================================
// Helpers
// =============================================================================
type Vec3 = [number, number, number];
const DEFAULT_POS: Vec3 = [0, 0, 0];
const DEFAULT_ROT: Vec3 = [0, 0, 0];
const DEFAULT_SCALE: Vec3 = [1, 1, 1];
function pos(v: Vec3 | null | undefined): Vec3 {
return v ?? DEFAULT_POS;
}
function rot(v: Vec3 | null | undefined): Vec3 {
return v ?? DEFAULT_ROT;
}
function scl(v: Vec3 | null | undefined): Vec3 {
return v ?? DEFAULT_SCALE;
}
function MaterialComponent({
material,
}: {
material: ThreeProps<"Box">["material"] | null | undefined;
}) {
if (!material) return <meshStandardMaterial />;
return (
<meshStandardMaterial
color={material.color ?? "#ffffff"}
metalness={material.metalness ?? 0}
roughness={material.roughness ?? 1}
emissive={material.emissive ?? "#000000"}
emissiveIntensity={material.emissiveIntensity ?? 1}
opacity={material.opacity ?? 1}
transparent={material.transparent ?? false}
wireframe={material.wireframe ?? false}
/>
);
}
// =============================================================================
// Component Implementations
// =============================================================================
/**
* React Three Fiber component implementations for json-render.
*
* Pass to `defineRegistry()` from `@json-render/react` to create a
* component registry for rendering JSON specs as 3D scenes.
*
* @example
* ```ts
* import { defineRegistry } from "@json-render/react";
* import { threeComponents } from "@json-render/react-three-fiber";
*
* const { registry } = defineRegistry(catalog, {
* components: { ...threeComponents },
* });
* ```
*/
export const threeComponents = {
// ── Primitives ─────────────────────────────────────────────────────────
Box: ({ props }: BaseComponentProps<ThreeProps<"Box">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<boxGeometry
args={[props.width ?? 1, props.height ?? 1, props.depth ?? 1]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
Sphere: ({ props }: BaseComponentProps<ThreeProps<"Sphere">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<sphereGeometry
args={[
props.radius ?? 1,
props.widthSegments ?? 32,
props.heightSegments ?? 16,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
Cylinder: ({ props }: BaseComponentProps<ThreeProps<"Cylinder">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<cylinderGeometry
args={[
props.radiusTop ?? 1,
props.radiusBottom ?? 1,
props.height ?? 1,
props.radialSegments ?? 32,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
Cone: ({ props }: BaseComponentProps<ThreeProps<"Cone">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<coneGeometry
args={[
props.radius ?? 1,
props.height ?? 1,
props.radialSegments ?? 32,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
Torus: ({ props }: BaseComponentProps<ThreeProps<"Torus">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<torusGeometry
args={[
props.radius ?? 1,
props.tube ?? 0.4,
props.radialSegments ?? 16,
props.tubularSegments ?? 48,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
Plane: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Plane">> & { children?: ReactNode }) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<planeGeometry args={[props.width ?? 1, props.height ?? 1]} />
{children ?? <MaterialComponent material={props.material} />}
</mesh>
),
Capsule: ({ props }: BaseComponentProps<ThreeProps<"Capsule">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<capsuleGeometry
args={[
props.radius ?? 0.5,
props.length ?? 1,
props.capSegments ?? 4,
props.radialSegments ?? 16,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
// ── Lights ─────────────────────────────────────────────────────────────
AmbientLight: ({ props }: BaseComponentProps<ThreeProps<"AmbientLight">>) => (
<ambientLight
color={props.color ?? "#ffffff"}
intensity={props.intensity ?? 1}
/>
),
DirectionalLight: ({
props,
}: BaseComponentProps<ThreeProps<"DirectionalLight">>) => (
<directionalLight
position={pos(props.position)}
rotation={rot(props.rotation)}
color={props.color ?? "#ffffff"}
intensity={props.intensity ?? 1}
castShadow={props.castShadow ?? false}
/>
),
PointLight: ({ props }: BaseComponentProps<ThreeProps<"PointLight">>) => (
<pointLight
position={pos(props.position)}
color={props.color ?? "#ffffff"}
intensity={props.intensity ?? 1}
distance={props.distance ?? 0}
decay={props.decay ?? 2}
castShadow={props.castShadow ?? false}
/>
),
SpotLight: ({ props }: BaseComponentProps<ThreeProps<"SpotLight">>) => (
<spotLight
position={pos(props.position)}
color={props.color ?? "#ffffff"}
intensity={props.intensity ?? 1}
distance={props.distance ?? 0}
decay={props.decay ?? 2}
angle={props.angle ?? Math.PI / 3}
penumbra={props.penumbra ?? 0}
castShadow={props.castShadow ?? false}
/>
),
// ── Container ──────────────────────────────────────────────────────────
Group: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Group">> & { children?: ReactNode }) => (
<group
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
>
{children}
</group>
),
// ── Model ──────────────────────────────────────────────────────────────
Model: ({ props }: BaseComponentProps<ThreeProps<"Model">>) => (
<Suspense fallback={null}>
<Gltf
src={props.url}
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
/>
</Suspense>
),
// ── Environment ────────────────────────────────────────────────────────
Environment: ({ props }: BaseComponentProps<ThreeProps<"Environment">>) => (
<DreiEnvironment
preset={props.preset ?? "sunset"}
background={props.background ?? false}
blur={props.blur ?? 0}
environmentIntensity={props.intensity ?? 1}
/>
),
Fog: ({ props }: BaseComponentProps<ThreeProps<"Fog">>) => (
<fog
attach="fog"
args={[props.color ?? "#cccccc", props.near ?? 10, props.far ?? 50]}
/>
),
GridHelper: ({ props }: BaseComponentProps<ThreeProps<"GridHelper">>) => (
<group
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
>
<gridHelper
args={[
props.size ?? 10,
props.divisions ?? 10,
props.color ?? "#888888",
props.secondaryColor ?? "#444444",
]}
/>
</group>
),
// ── Text ───────────────────────────────────────────────────────────────
Text3D: ({ props }: BaseComponentProps<ThreeProps<"Text3D">>) => (
<DreiText
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
fontSize={props.fontSize ?? 1}
color={props.color ?? "#ffffff"}
anchorX={props.anchorX ?? "center"}
anchorY={props.anchorY ?? "middle"}
maxWidth={props.maxWidth ?? undefined}
>
{props.text}
</DreiText>
),
// ── Effects / Atmosphere ──────────────────────────────────────────────
Sparkles: ({ props }: BaseComponentProps<ThreeProps<"Sparkles">>) => (
<DreiSparkles
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
count={props.count ?? 50}
speed={props.speed ?? 0.4}
opacity={props.opacity ?? 1}
color={props.color ?? "#ffffff"}
size={props.size ?? 1}
noise={props.noise ?? 1}
/>
),
Stars: ({ props }: BaseComponentProps<ThreeProps<"Stars">>) => (
<DreiStars
radius={props.radius ?? 100}
depth={props.depth ?? 50}
count={props.count ?? 5000}
factor={props.factor ?? 4}
saturation={props.saturation ?? 0}
fade={props.fade ?? true}
speed={props.speed ?? 1}
/>
),
Sky: ({ props }: BaseComponentProps<ThreeProps<"Sky">>) => (
<DreiSky
distance={props.distance ?? 450000}
sunPosition={props.sunPosition ?? [0, 1, 0]}
inclination={props.inclination ?? undefined}
azimuth={props.azimuth ?? undefined}
mieCoefficient={props.mieCoefficient ?? 0.005}
mieDirectionalG={props.mieDirectionalG ?? 0.8}
rayleigh={props.rayleigh ?? 0.5}
turbidity={props.turbidity ?? 10}
/>
),
Cloud: ({ props }: BaseComponentProps<ThreeProps<"Cloud">>) => (
<DreiClouds limit={200}>
<DreiCloud
position={pos(props.position)}
seed={props.seed ?? undefined}
segments={props.segments ?? 20}
bounds={props.bounds ?? [10, 2, 10]}
volume={props.volume ?? 6}
speed={props.speed ?? 0.2}
fade={props.fade ?? 30}
opacity={props.opacity ?? 0.6}
color={props.color ?? "#ffffff"}
growth={props.growth ?? 4}
/>
</DreiClouds>
),
// ── Special Materials ───────────────────────────────────────────────
GlassSphere: ({ props }: BaseComponentProps<ThreeProps<"GlassSphere">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<sphereGeometry
args={[
props.radius ?? 1,
props.widthSegments ?? 64,
props.heightSegments ?? 32,
]}
/>
<MeshTransmissionMaterial
color={props.color ?? "#ffffff"}
transmission={props.transmission ?? 1}
thickness={props.thickness ?? 0.5}
roughness={props.roughness ?? 0}
chromaticAberration={props.chromaticAberration ?? 0.06}
ior={props.ior ?? 1.5}
distortion={props.distortion ?? 0}
distortionScale={props.distortionScale ?? 0.3}
temporalDistortion={props.temporalDistortion ?? 0.5}
samples={props.samples ?? 10}
resolution={props.resolution ?? 256}
backside
/>
</mesh>
),
GlassBox: ({ props }: BaseComponentProps<ThreeProps<"GlassBox">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<boxGeometry
args={[props.width ?? 1, props.height ?? 1, props.depth ?? 1]}
/>
<MeshTransmissionMaterial
color={props.color ?? "#ffffff"}
transmission={props.transmission ?? 1}
thickness={props.thickness ?? 0.5}
roughness={props.roughness ?? 0}
chromaticAberration={props.chromaticAberration ?? 0.06}
ior={props.ior ?? 1.5}
distortion={props.distortion ?? 0}
distortionScale={props.distortionScale ?? 0.3}
temporalDistortion={props.temporalDistortion ?? 0.5}
samples={props.samples ?? 10}
resolution={props.resolution ?? 256}
backside
/>
</mesh>
),
DistortSphere: ({
props,
}: BaseComponentProps<ThreeProps<"DistortSphere">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<sphereGeometry
args={[
props.radius ?? 1,
props.widthSegments ?? 64,
props.heightSegments ?? 32,
]}
/>
<MeshDistortMaterial
color={props.color ?? "#ff6600"}
speed={props.speed ?? 2}
distort={props.distort ?? 0.5}
metalness={props.metalness ?? 0}
roughness={props.roughness ?? 0.2}
/>
</mesh>
),
// ── Extended Geometry ───────────────────────────────────────────────
TorusKnot: ({ props }: BaseComponentProps<ThreeProps<"TorusKnot">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
>
<torusKnotGeometry
args={[
props.radius ?? 1,
props.tube ?? 0.4,
props.tubularSegments ?? 64,
props.radialSegments ?? 8,
props.p ?? 2,
props.q ?? 3,
]}
/>
<MaterialComponent material={props.material} />
</mesh>
),
RoundedBox: ({ props }: BaseComponentProps<ThreeProps<"RoundedBox">>) => (
<DreiRoundedBox
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
castShadow={props.castShadow ?? false}
receiveShadow={props.receiveShadow ?? false}
args={[props.width ?? 1, props.height ?? 1, props.depth ?? 1]}
radius={props.radius ?? 0.05}
smoothness={props.smoothness ?? 4}
>
<MaterialComponent material={props.material} />
</DreiRoundedBox>
),
// ── Shadows / Staging ───────────────────────────────────────────────
ContactShadows: ({
props,
}: BaseComponentProps<ThreeProps<"ContactShadows">>) => (
<DreiContactShadows
position={pos(props.position)}
rotation={rot(props.rotation)}
opacity={props.opacity ?? 0.5}
width={props.width ?? 10}
height={props.height ?? 10}
blur={props.blur ?? 2}
near={props.near ?? undefined}
far={props.far ?? 10}
smooth={props.smooth ?? true}
resolution={props.resolution ?? 256}
frames={props.frames ?? undefined}
color={props.color ?? "#000000"}
/>
),
Float: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Float">> & { children?: ReactNode }) => (
<DreiFloat
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
speed={props.speed ?? 1}
rotationIntensity={props.rotationIntensity ?? 1}
floatIntensity={props.floatIntensity ?? 1}
enabled={props.enabled ?? true}
>
{children}
</DreiFloat>
),
ReflectorPlane: ({
props,
}: BaseComponentProps<ThreeProps<"ReflectorPlane">>) => (
<mesh
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
>
<planeGeometry args={[props.width ?? 10, props.height ?? 10]} />
<MeshReflectorMaterial
color={props.color ?? "#888888"}
resolution={props.resolution ?? 1024}
blur={props.blur ? [props.blur, props.blur] : [300, 100]}
mirror={props.mirror ?? 0.5}
mixBlur={props.mixBlur ?? 10}
mixStrength={props.mixStrength ?? 2}
depthScale={props.depthScale ?? 0.1}
metalness={props.metalness ?? 0.5}
roughness={props.roughness ?? 1}
/>
</mesh>
),
Backdrop: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Backdrop">> & { children?: ReactNode }) => (
<DreiBackdrop
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
floor={props.floor ?? 0.25}
segments={props.segments ?? 20}
receiveShadow={props.receiveShadow ?? true}
>
{children}
</DreiBackdrop>
),
// ── Crazy / Magic ─────────────────────────────────────────────────────
MeshPortalMaterial: ({
props,
children,
}: BaseComponentProps<ThreeProps<"MeshPortalMaterial">> & {
children?: ReactNode;
}) => (
<DreiMeshPortalMaterial
blend={props.blend ?? 0}
blur={props.blur ?? 0}
resolution={props.resolution ?? 512}
>
{children}
</DreiMeshPortalMaterial>
),
HtmlLabel: ({ props }: BaseComponentProps<ThreeProps<"HtmlLabel">>) => (
<DreiHtml
position={pos(props.position)}
transform={props.transform ?? true}
distanceFactor={props.distanceFactor ?? 10}
center={props.center ?? true}
style={{
color: props.color ?? "#ffffff",
fontSize: props.fontSize ? `${props.fontSize}px` : "16px",
fontFamily: "system-ui, sans-serif",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{props.text}
</DreiHtml>
),
// ── Post-Processing ────────────────────────────────────────────────────
EffectComposer: ({
props,
children,
}: BaseComponentProps<ThreeProps<"EffectComposer">> & {
children?: ReactNode;
}) => {
if (props.enabled === false) return null;
return (
<postprocessing.EffectComposer multisampling={props.multisampling ?? 8}>
{children}
</postprocessing.EffectComposer>
);
},
Bloom: ({ props }: BaseComponentProps<ThreeProps<"Bloom">>) => (
<postprocessing.Bloom
intensity={props.intensity ?? 1.5}
luminanceThreshold={props.luminanceThreshold ?? 0.1}
luminanceSmoothing={props.luminanceSmoothing ?? 0.025}
mipmapBlur={props.mipmapBlur ?? true}
/>
),
Glitch: ({ props }: BaseComponentProps<ThreeProps<"Glitch">>) => {
const delay = props.delay ? new THREE.Vector2(...props.delay) : undefined;
const duration = props.duration
? new THREE.Vector2(...props.duration)
: undefined;
const strength = props.strength
? new THREE.Vector2(...props.strength)
: undefined;
return (
<postprocessing.Glitch
delay={delay}
duration={duration}
strength={strength}
active={props.active ?? true}
ratio={props.ratio ?? 0.85}
/>
);
},
Vignette: ({ props }: BaseComponentProps<ThreeProps<"Vignette">>) => (
<postprocessing.Vignette
offset={props.offset ?? 0.5}
darkness={props.darkness ?? 0.5}
/>
),
// ── Animation Wrappers ──────────────────────────────────────────────
WarpTunnel: (() => {
function TunnelRing({
index,
ringCount,
radius,
length,
speed,
tubeRadius,
color,
}: {
index: number;
ringCount: number;
radius: number;
length: number;
speed: number;
tubeRadius: number;
color: THREE.Color;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const spacing = length / ringCount;
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
const z =
((((-index * spacing + t * speed) % length) + length) % length) -
length;
meshRef.current.position.z = z;
const distFactor = 1 - Math.abs(z) / length;
const s = 0.3 + distFactor * 0.7;
meshRef.current.scale.set(s, s, s);
});
return (
<mesh ref={meshRef} rotation={[0, 0, (index * Math.PI) / 12]}>
<torusGeometry args={[radius, tubeRadius, 16, 64]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={3}
transparent
opacity={0.7}
side={THREE.DoubleSide}
/>
</mesh>
);
}
return ({ props }: BaseComponentProps<ThreeProps<"WarpTunnel">>) => {
const ringCount = props.ringCount ?? 80;
const radius = props.radius ?? 3;
const length = props.length ?? 40;
const speed = props.speed ?? 8;
const tubeRadius = props.tubeRadius ?? 0.02;
const color1 = props.color1 ?? "#00ffff";
const color2 = props.color2 ?? "#ff00ff";
const colors = useMemo(() => {
const c1 = new THREE.Color(color1);
const c2 = new THREE.Color(color2);
return Array.from({ length: ringCount }, (_, i) =>
new THREE.Color().lerpColors(c1, c2, i / ringCount),
);
}, [ringCount, color1, color2]);
return (
<group
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
>
{colors.map((color, i) => (
<TunnelRing
key={i}
index={i}
ringCount={ringCount}
radius={radius}
length={length}
speed={speed}
tubeRadius={tubeRadius}
color={color}
/>
))}
</group>
);
};
})(),
Spin: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Spin">> & { children?: ReactNode }) => {
const ref = useRef<THREE.Group>(null);
const speed = props.speed ?? 1;
const axis = props.axis ?? "y";
useFrame((_, delta) => {
if (!ref.current) return;
if (axis === "x") ref.current.rotation.x += delta * speed;
else if (axis === "z") ref.current.rotation.z += delta * speed;
else ref.current.rotation.y += delta * speed;
});
return (
<group
ref={ref}
position={pos(props.position)}
rotation={rot(props.rotation)}
scale={scl(props.scale)}
>
{children}
</group>
);
},
Orbit: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Orbit">> & { children?: ReactNode }) => {
const ref = useRef<THREE.Group>(null);
const speed = props.speed ?? 1;
const radius = props.radius ?? 3;
const tilt = props.tilt ?? 0;
const baseY = props.position?.[1] ?? 0;
useFrame((state) => {
if (!ref.current) return;
const t = state.clock.elapsedTime * speed;
ref.current.position.x = Math.cos(t) * radius;
ref.current.position.z = Math.sin(t) * radius;
ref.current.position.y = baseY + Math.sin(t * 0.7) * tilt;
});
return <group ref={ref}>{children}</group>;
},
Pulse: ({
props,
children,
}: BaseComponentProps<ThreeProps<"Pulse">> & { children?: ReactNode }) => {
const ref = useRef<THREE.Group>(null);
const speed = props.speed ?? 1;
const min = props.min ?? 0.8;
const max = props.max ?? 1.2;
useFrame((state) => {
if (!ref.current) return;
const t = (Math.sin(state.clock.elapsedTime * speed) + 1) / 2;
const s = min + t * (max - min);
ref.current.scale.setScalar(s);
});
return (
<group
ref={ref}
position={pos(props.position)}
rotation={rot(props.rotation)}
>
{children}
</group>
);
},
CameraShake: ({ props }: BaseComponentProps<ThreeProps<"CameraShake">>) => (
<DreiCameraShake
intensity={props.intensity ?? 0.5}
maxYaw={props.maxYaw ?? 0.1}
maxPitch={props.maxPitch ?? 0.1}
maxRoll={props.maxRoll ?? 0.1}
/>
),
// ── Camera / Controls ──────────────────────────────────────────────
PerspectiveCamera: ({
props,
}: BaseComponentProps<ThreeProps<"PerspectiveCamera">>) => (
<DreiPerspectiveCamera
position={pos(props.position)}
rotation={rot(props.rotation)}
fov={props.fov ?? 50}
near={props.near ?? 0.1}
far={props.far ?? 1000}
makeDefault={props.makeDefault ?? false}
/>
),
OrbitControls: ({
props,
}: BaseComponentProps<ThreeProps<"OrbitControls">>) => (
<DreiOrbitControls
enableDamping={props.enableDamping ?? true}
dampingFactor={props.dampingFactor ?? 0.05}
enableZoom={props.enableZoom ?? true}
enablePan={props.enablePan ?? true}
enableRotate={props.enableRotate ?? true}
minDistance={props.minDistance ?? undefined}
maxDistance={props.maxDistance ?? undefined}
minPolarAngle={props.minPolarAngle ?? undefined}
maxPolarAngle={props.maxPolarAngle ?? undefined}
autoRotate={props.autoRotate ?? false}
autoRotateSpeed={props.autoRotateSpeed ?? 2}
target={props.target ?? undefined}
/>
),
};
+25
View File
@@ -0,0 +1,25 @@
// Component implementations
export { threeComponents } from "./components";
// Catalog definitions (also available via @json-render/react-three-fiber/catalog)
export {
threeComponentDefinitions,
type ComponentDefinition,
type ThreeProps,
} from "./catalog";
// Shared schemas
export {
vector3Schema,
materialSchema,
transformProps,
shadowProps,
} from "./schemas";
// Renderer
export {
ThreeRenderer,
ThreeCanvas,
type ThreeRendererProps,
type ThreeCanvasProps,
} from "./renderer";
+19
View File
@@ -0,0 +1,19 @@
import type { ThreeElements } from "@react-three/fiber";
declare module "react/jsx-runtime" {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
declare module "react/jsx-dev-runtime" {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
declare module "react" {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
+144
View File
@@ -0,0 +1,144 @@
import { type ReactNode } from "react";
import { Canvas, type CanvasProps } from "@react-three/fiber";
import {
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
type ComponentRegistry,
type ComponentRenderer,
} from "@json-render/react";
import type { Spec, StateStore, ComputedFunction } from "@json-render/core";
// =============================================================================
// ThreeRenderer
// =============================================================================
export interface ThreeRendererProps {
/** The spec to render as a 3D scene */
spec: Spec | null;
/** Component registry */
registry: ComponentRegistry;
/**
* External store (controlled mode). When provided, `initialState` and
* `onStateChange` are ignored.
*/
store?: StateStore;
/** Initial state model (uncontrolled mode) */
initialState?: Record<string, unknown>;
/** Action handlers */
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
fallback?: ComponentRenderer;
/** Additional R3F elements to render alongside the spec (lights, helpers, etc.) */
children?: ReactNode;
}
/**
* Renders a json-render spec as a React Three Fiber scene graph.
*
* Place inside a `<Canvas>` or use `<ThreeCanvas>` for a convenience wrapper.
*
* @example
* ```tsx
* <Canvas shadows>
* <ThreeRenderer spec={spec} registry={registry} />
* </Canvas>
* ```
*/
export function ThreeRenderer({
spec,
registry,
store,
initialState,
handlers,
functions,
onStateChange,
loading,
fallback,
children,
}: ThreeRendererProps) {
return (
<StateProvider
store={store}
initialState={initialState ?? spec?.state}
onStateChange={onStateChange}
>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={handlers}>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
{children}
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
// =============================================================================
// ThreeCanvas
// =============================================================================
export interface ThreeCanvasProps extends ThreeRendererProps {
/** Enable shadows on the Canvas */
shadows?: CanvasProps["shadows"];
/** Canvas camera config */
camera?: CanvasProps["camera"];
/** Canvas className */
className?: string;
/** Canvas style */
style?: React.CSSProperties;
/** Additional Canvas props */
canvasProps?: Omit<CanvasProps, "shadows" | "camera" | "className" | "style">;
}
/**
* Convenience wrapper that sets up a `<Canvas>` with a `<ThreeRenderer>` inside.
*
* @example
* ```tsx
* <ThreeCanvas
* spec={spec}
* registry={registry}
* shadows
* camera={{ position: [5, 5, 5], fov: 50 }}
* style={{ width: "100%", height: "100vh" }}
* />
* ```
*/
export function ThreeCanvas({
shadows,
camera,
className,
style,
canvasProps,
...rendererProps
}: ThreeCanvasProps) {
return (
<Canvas
shadows={shadows}
camera={camera}
className={className}
style={style}
{...canvasProps}
>
<ThreeRenderer {...rendererProps} />
</Canvas>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
export const vector3Schema = z.tuple([z.number(), z.number(), z.number()]);
export const materialSchema = z.object({
color: z.string().nullable(),
metalness: z.number().nullable(),
roughness: z.number().nullable(),
emissive: z.string().nullable(),
emissiveIntensity: z.number().nullable(),
opacity: z.number().nullable(),
transparent: z.boolean().nullable(),
wireframe: z.boolean().nullable(),
});
export const transformProps = {
position: vector3Schema.nullable(),
rotation: vector3Schema.nullable(),
scale: vector3Schema.nullable(),
} as const;
export const shadowProps = {
castShadow: z.boolean().nullable(),
receiveShadow: z.boolean().nullable(),
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/catalog.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"react",
"react-dom",
"@json-render/core",
"@json-render/react",
"@react-three/fiber",
"@react-three/drei",
"three",
"zod",
],
});
+243
View File
@@ -0,0 +1,243 @@
# @json-render/solid
SolidJS renderer for json-render. Turn JSON specs into Solid components with data binding, visibility, actions, validation, and streaming.
## Installation
```bash
npm install @json-render/core @json-render/solid zod
```
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/solid/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input with optional state binding",
},
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
```
### 2. Define Component Implementations
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions.
```tsx
import { defineRegistry, useBoundProp } from "@json-render/solid";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => (
<div class="card">
<h3>{renderProps.element.props.title as string}</h3>
{renderProps.children}
</div>
),
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
Input: (renderProps) => {
const [value, setValue] = useBoundProp(
renderProps.element.props.value,
renderProps.bindings?.value,
);
return (
<label>
{renderProps.element.props.label as string}
<input
value={String(value() ?? "")}
placeholder={String(renderProps.element.props.placeholder ?? "")}
onInput={(e) => setValue(e.currentTarget.value)}
/>
</label>
);
},
},
actions: {
submit: async () => {},
cancel: async () => {},
},
});
```
### 3. Render Specs
```tsx
import { Renderer, StateProvider, ActionProvider } from "@json-render/solid";
import { registry } from "./registry";
export function App(props: { spec: any }) {
return (
<StateProvider initialState={{ form: { name: "" } }}>
<ActionProvider handlers={{ submit: () => console.log("submit") }}>
<Renderer spec={props.spec} registry={registry} />
</ActionProvider>
</StateProvider>
);
}
```
## Spec Format
`@json-render/solid` uses the same flat element map format as React/Vue:
```typescript
interface Spec {
root: string;
elements: Record<string, UIElement>;
state?: Record<string, unknown>;
}
interface UIElement {
type: string;
props: Record<string, unknown>;
children?: string[];
visible?: VisibilityCondition;
watch?: Record<string, ActionBinding | ActionBinding[]>;
}
```
## Providers
| Provider | Purpose |
| --------------------- | ---------------------------------------------------- |
| `StateProvider` | State model and JSON Pointer read/write APIs |
| `ActionProvider` | Action dispatch, loading tracking, confirmation flow |
| `VisibilityProvider` | Visibility condition evaluation from current state |
| `ValidationProvider` | Field-level and full-form validation |
| `RepeatScopeProvider` | Repeat context (`$item`, `$index`, `$bindItem`) |
| `JSONUIProvider` | Combined provider wiring for renderer trees |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or the component returned by `createRenderer`.
When `store` is provided, `initialState` and `onStateChange` are ignored.
```tsx
import { createStateStore, StateProvider } from "@json-render/solid";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>{/* ... */}</StateProvider>;
```
## Hooks
| Hook | Purpose |
| --------------------------------------------- | ----------------------------------------------------- |
| `useStateStore()` | Access `state`, `get`, `set`, `update`, `getSnapshot` |
| `useStateValue(path)` | Read a value by JSON Pointer path via accessor |
| `useStateBinding(path)` | Legacy two-way binding helper returning an accessor |
| `useVisibility()` / `useIsVisible()` | Visibility context and checks |
| `useActions()` / `useAction()` | Action context and single-action helper |
| `useValidation()` / `useOptionalValidation()` | Validation context (throwing/non-throwing) |
| `useFieldValidation(path, config)` | Field state accessors plus validate/touch/clear |
| `useBoundProp(value, binding)` | Fine-grained two-way binding helper |
| `useUIStream(options)` | Stream UI specs from an endpoint |
| `useChatUI(options)` | Chat-style spec generation hook |
## Built-in Actions
These actions are available in the Solid schema and handled by `ActionProvider`:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
`setState`/`pushState`/`removeState` mutate the state model. `validateForm` validates registered fields and writes `{ valid, errors }` to state (`/formValidation` by default).
## Events and Action Binding
Components can use either `emit("event")` or `on("event")`.
- `emit` fires named event bindings directly.
- `on` returns an `EventHandle` with `emit`, `bound`, and `shouldPreventDefault`.
This mirrors the React package API while preserving Solid's fine-grained reactivity.
## Streaming
`useUIStream` and `useChatUI` support JSON patch streaming and mixed text/spec data parts.
```tsx
import { useUIStream } from "@json-render/solid";
const stream = useUIStream({ api: "/api/generate" });
await stream.send("Build me a dashboard");
```
## Key Exports
| Export | Purpose |
| ------------------ | -------------------------------------------------------------------------- |
| `defineRegistry` | Create catalog-aware component and action registry helpers |
| `Renderer` | Render a `Spec` with a component registry |
| `createRenderer` | Build an app-level renderer with provider wiring |
| `JSONUIProvider` | Combined provider tree (`state` + `visibility` + `validation` + `actions`) |
| `schema` | Solid element schema with built-in actions |
| `createStateStore` | Framework-agnostic in-memory `StateStore` |
### Types
| Export | Purpose |
| --------------------------- | -------------------------------------------------------- |
| `ComponentContext` | Catalog-aware component context type |
| `BaseComponentProps` | Catalog-agnostic component props type |
| `EventHandle` | Event metadata (`emit`, `bound`, `shouldPreventDefault`) |
| `StateStore` | Controlled state backend interface |
| `StateModel` | Renderer state model type |
| `SolidSchema` / `SolidSpec` | Solid schema/spec types |
## Differences from `@json-render/react`
Most APIs are intentionally aligned, but there are runtime behavior differences due to Solid:
- Solid components run once, then update via signals.
- Keep changing reads inside JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when values should remain reactive.
- Hooks that read changing state return accessors; call them inside JSX or effects.
## Documentation
Full docs: [json-render.dev/docs/api/solid](https://json-render.dev/docs/api/solid)
## License
Apache-2.0
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@json-render/solid",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
"keywords": [
"json",
"ui",
"solid",
"solidjs",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/solid"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"esbuild-plugin-solid": "^0.6.0",
"solid-js": "^1.9.0",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"solid-js": "^1.9.0",
"zod": "^4.0.0"
}
}
+154
View File
@@ -0,0 +1,154 @@
import type { JSX } from "solid-js";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
/**
* State setter function for updating application state
*/
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
/**
* Handle returned by the `on()` function for a specific event.
* Provides metadata about the event binding and a method to fire it.
*
* @example
* ```ts
* const press = on("press");
* if (press.shouldPreventDefault) e.preventDefault();
* press.emit();
* ```
*/
export interface EventHandle {
/** Fire the event (resolve action bindings) */
emit: () => void;
/** Whether any binding requested preventDefault */
shouldPreventDefault: boolean;
/** Whether any handler is bound to this event */
bound: boolean;
}
/**
* Catalog-agnostic base type for component render function arguments.
* Use this when building reusable component libraries that are not tied
* to a specific catalog.
*
* @example
* ```ts
* const Card = (ctx: BaseComponentProps<{ title?: string }>) => (
* <div>{ctx.props.title}{ctx.children}</div>
* );
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: JSX.Element;
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Context passed to component render functions
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
* return <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for Solid
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => (
* <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* );
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => JSX.Element;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Button: (ctx) => <button>{ctx.props.label}</button>,
* Input: (ctx) => <input placeholder={ctx.props.placeholder} />,
* };
*/
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
// =============================================================================
// Action Types
// =============================================================================
/**
* Action handler function type
* @example
* const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
* const data = await fetch('/api/customers');
* setState(prev => ({ ...prev, customers: data }));
* };
*/
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Registry of all action handlers for a catalog
* @example
* const actions: Actions<typeof myCatalog> = {
* viewCustomers: async (params, setState) => { ... },
* createCustomer: async (params, setState) => { ... },
* };
*/
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
/**
* True when the catalog declares at least one action, false otherwise.
* Used by defineRegistry to conditionally require the `actions` field.
*/
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
// DO NOT destructure props — Solid requires proxy access
function Button(props: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => props.emit("press")}>
{props.element.props.label}
</button>
);
}
function Text(props: ComponentRenderProps<{ text: unknown }>) {
const display = () => {
const v = props.element.props.text;
return typeof v === "string" ? v : JSON.stringify(v);
};
return <span data-testid={`text-${props.element.type}`}>{display()}</span>;
}
let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
function StateProbe() {
const ctx = useStateStore();
probeGetSnapshot = ctx.getSnapshot;
return <div data-testid="state-probe" />;
}
const registry = {
Button,
Text,
};
function getProbeState(): Record<string, unknown> {
return probeGetSnapshot!();
}
describe("chained actions: live $state resolution (#141)", () => {
it("setState after pushState sees the post-push value via $state", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: ["initial"], observed: "not yet set" },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Add Item" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "new-item" },
},
{
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["initial", "new-item"]);
expect(state.observed).toEqual(["initial", "new-item"]);
});
});
it("multiple pushState + setState chain resolves correctly", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: [], snapshot: null },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "a" },
},
{
action: "pushState",
params: { statePath: "/items", value: "b" },
},
{
action: "setState",
params: {
statePath: "/snapshot",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["a", "b"]);
expect(state.snapshot).toEqual(["a", "b"]);
});
});
it("setState reading a path mutated by an earlier setState sees fresh value", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { counter: 0, counterCopy: -1 },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "setState",
params: { statePath: "/counter", value: 42 },
},
{
action: "setState",
params: {
statePath: "/counterCopy",
value: { $state: "/counter" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.counter).toBe(42);
expect(state.counterCopy).toBe(42);
});
});
});
@@ -0,0 +1,346 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@solidjs/testing-library";
import type { JSX } from "solid-js";
import { StateProvider, useStateStore } from "./state";
import {
ValidationProvider,
useValidation,
useFieldValidation,
} from "./validation";
import { ActionProvider, useActions, useAction } from "./actions";
/**
* Build the full provider tree inline so that Solid's eager JSX evaluation
* always runs ActionProvider/ValidationProvider INSIDE StateProvider's scope.
*
* IMPORTANT: Do NOT pre-build JSX fragments as variables — Solid evaluates
* JSX eagerly, so `<ActionProvider>` would call `useStateStore()` before the
* StateProvider context is available.
*/
function withProviders<T>(
hook: () => T,
options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
} = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return result;
}
function withFullProviders(options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
}): {
stateCtx: ReturnType<typeof useStateStore>;
actionsCtx: ReturnType<typeof useActions>;
validationCtx?: ReturnType<typeof useValidation>;
} {
let stateCtx!: ReturnType<typeof useStateStore>;
let actionsCtx!: ReturnType<typeof useActions>;
let validationCtx: ReturnType<typeof useValidation> | undefined;
function TestComponent(): JSX.Element {
stateCtx = useStateStore();
actionsCtx = useActions();
if (options.withValidation) {
validationCtx = useValidation();
}
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return { stateCtx, actionsCtx, validationCtx };
}
describe("ActionProvider — provide/inject", () => {
it("useActions() throws outside a provider", () => {
expect(() => useActions()).toThrow(
"useActions must be used within an ActionProvider",
);
});
});
describe("ActionProvider — built-in setState", () => {
it("executes setState and updates state", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { count: 0 },
});
await actionsCtx.execute({
action: "setState",
params: { statePath: "/count", value: 5 },
});
expect(stateCtx.get("/count")).toBe(5);
});
});
describe("ActionProvider — built-in pushState", () => {
it("appends to existing array", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b"] },
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/items", value: "c" },
});
expect(stateCtx.get("/items")).toEqual(["a", "b", "c"]);
});
it("creates array if path does not exist", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: {},
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/newList", value: "first" },
});
expect(stateCtx.get("/newList")).toEqual(["first"]);
});
});
describe("ActionProvider — built-in removeState", () => {
it("removes item by index", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b", "c"] },
});
await actionsCtx.execute({
action: "removeState",
params: { statePath: "/items", index: 1 },
});
expect(stateCtx.get("/items")).toEqual(["a", "c"]);
});
});
describe("ActionProvider — built-in push/pop navigation", () => {
it("push sets /currentScreen and /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "home" },
});
await actionsCtx.execute({
action: "push",
params: { screen: "settings" },
});
expect(stateCtx.get("/currentScreen")).toBe("settings");
expect(stateCtx.get("/navStack")).toEqual(["home"]);
});
it("pop restores previous screen from /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "settings", navStack: ["home"] },
});
await actionsCtx.execute({ action: "pop" });
expect(stateCtx.get("/currentScreen")).toBe("home");
expect(stateCtx.get("/navStack")).toEqual([]);
});
});
describe("ActionProvider — custom handlers", () => {
it("executes custom handler with params", async () => {
const customHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: customHandler },
});
await actionsCtx.execute({
action: "myAction",
params: { foo: "bar" },
});
expect(customHandler).toHaveBeenCalledWith({ foo: "bar" });
});
it("console.warn for unknown action", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({});
await actionsCtx.execute({ action: "unknownAction" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("unknownAction"),
);
warnSpy.mockRestore();
});
it("tracks loading state during async action execution", async () => {
let resolveHandler!: () => void;
const slowHandler = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveHandler = resolve;
}),
);
let executeFn!: ReturnType<typeof useActions>["execute"];
const { findByTestId } = render(() => {
function Inner(): JSX.Element {
const actions = useActions();
executeFn = actions.execute;
return (
<span data-testid="loading">
{actions.loadingActions.has("slowAction") ? "true" : "false"}
</span>
);
}
return (
<StateProvider initialState={{}}>
<ActionProvider handlers={{ slowAction: slowHandler }}>
<Inner />
</ActionProvider>
</StateProvider>
);
});
// Before execution — not loading
const el = await findByTestId("loading");
expect(el.textContent).toBe("false");
const executePromise = executeFn({ action: "slowAction" });
// During execution — loading (need to wait for Solid's reactivity to flush)
await vi.waitFor(() => {
expect(el.textContent).toBe("true");
});
resolveHandler();
await executePromise;
// After execution — no longer loading
await vi.waitFor(() => {
expect(el.textContent).toBe("false");
});
});
it("registerHandler allows dynamic handler registration", async () => {
const dynamicHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({});
actionsCtx.registerHandler("dynamicAction", dynamicHandler);
await actionsCtx.execute({
action: "dynamicAction",
params: { x: 1 },
});
expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 });
});
it("handler receives resolved params object", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: handler },
});
await actionsCtx.execute({
action: "myAction",
params: { x: 1, y: "hello" },
});
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
});
});
describe("ActionProvider — validateForm", () => {
it("writes { valid, errors } to state", async () => {
const { stateCtx, actionsCtx, validationCtx } = withFullProviders({
initialState: {},
withValidation: true,
});
validationCtx!.registerField("/form/email", {
checks: [{ type: "required", message: "Required" }],
});
await actionsCtx.execute({ action: "validateForm" });
expect(stateCtx.get("/formValidation")).toEqual({
valid: false,
errors: { "/form/email": ["Required"] },
});
});
it("warns without ValidationProvider", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({
withValidation: false,
});
await actionsCtx.execute({ action: "validateForm" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("validateForm action was dispatched"),
);
warnSpy.mockRestore();
});
});
describe("useAction", () => {
it("returns { execute, isLoading: false } before execution", () => {
const result = withProviders(() => useAction({ action: "myAction" }), {
handlers: { myAction: vi.fn() },
});
expect(typeof result.execute).toBe("function");
expect(result.isLoading).toBe(false);
});
});
+400
View File
@@ -0,0 +1,400 @@
import {
createContext,
useContext,
createSignal,
type ParentProps,
type JSX,
} from "solid-js";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
let idCounter = 0;
function generateUniqueId(): string {
idCounter += 1;
return `${Date.now()}-${idCounter}`;
}
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
): unknown {
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get));
}
if (typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = deepResolveValue(val, get);
}
return resolved;
}
return value;
}
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
resolve: () => void;
reject: () => void;
}
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
pendingConfirmation: PendingConfirmation | null;
execute: (binding: ActionBinding) => Promise<void>;
confirm: () => void;
cancel: () => void;
registerHandler: (name: string, handler: ActionHandler) => void;
}
const ActionContext = createContext<ActionContextValue | null>(null);
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
}
export function ActionProvider(props: ParentProps<ActionProviderProps>) {
const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const [handlers, setHandlers] = createSignal<Record<string, ActionHandler>>(
props.handlers ?? {},
);
const [loadingActions, setLoadingActions] = createSignal<Set<string>>(
new Set(),
);
const [pendingConfirmation, setPendingConfirmation] =
createSignal<PendingConfirmation | null>(null);
const registerHandler = (name: string, handler: ActionHandler) => {
setHandlers((prev) => ({ ...prev, [name]: handler }));
};
const execute = async (binding: ActionBinding) => {
const resolved = resolveAction(binding, getSnapshot());
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
}
return;
}
if (resolved.action === "pushState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, get);
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
}
}
return;
}
if (resolved.action === "removeState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(
statePath,
arr.filter((_, i) => i !== index),
);
}
return;
}
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
if (screen) {
const currentScreen = get("/currentScreen") as string | undefined;
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
set("/navStack", [...navStack, currentScreen]);
} else {
set("/navStack", [...navStack, ""]);
}
set("/currentScreen", screen);
}
return;
}
if (resolved.action === "pop") {
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (navStack.length > 0) {
const previousScreen = navStack[navStack.length - 1];
set("/navStack", navStack.slice(0, -1));
if (previousScreen) {
set("/currentScreen", previousScreen);
} else {
set("/currentScreen", undefined);
}
}
return;
}
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers()[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
if (resolved.confirm) {
return new Promise<void>((resolve, reject) => {
setPendingConfirmation({
action: resolved,
handler,
resolve: () => {
setPendingConfirmation(null);
resolve();
},
reject: () => {
setPendingConfirmation(null);
reject(new Error("Action cancelled"));
},
});
}).then(async () => {
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name: string) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
});
}
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name: string) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
};
const confirm = () => {
pendingConfirmation()?.resolve();
};
const cancel = () => {
pendingConfirmation()?.reject();
};
const ctx: ActionContextValue = {
get handlers() {
return handlers();
},
get loadingActions() {
return loadingActions();
},
get pendingConfirmation() {
return pendingConfirmation();
},
execute,
confirm,
cancel,
registerHandler,
};
return (
<ActionContext.Provider value={ctx}>
{props.children}
</ActionContext.Provider>
);
}
export function useActions(): ActionContextValue {
const ctx = useContext(ActionContext);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: boolean;
} {
const actions = useActions();
return {
execute: () => actions.execute(binding),
get isLoading() {
return actions.loadingActions.has(binding.action);
},
};
}
export interface ConfirmDialogProps {
confirm: ActionConfirm;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog(props: ConfirmDialogProps): JSX.Element {
const isDanger = () => props.confirm.variant === "danger";
return (
<div
style={{
position: "fixed",
inset: "0",
"background-color": "rgba(0, 0, 0, 0.5)",
display: "flex",
"align-items": "center",
"justify-content": "center",
"z-index": 50,
}}
onClick={props.onCancel}
>
<div
style={{
"background-color": "white",
"border-radius": "8px",
padding: "24px",
"max-width": "400px",
width: "100%",
"box-shadow": "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 8px 0",
"font-size": "18px",
"font-weight": "600",
}}
>
{props.confirm.title}
</h3>
<p
style={{
margin: "0 0 24px 0",
color: "#6b7280",
}}
>
{props.confirm.message}
</p>
<div
style={{
display: "flex",
gap: "12px",
"justify-content": "flex-end",
}}
>
<button
onClick={props.onCancel}
style={{
padding: "8px 16px",
"border-radius": "6px",
border: "1px solid #d1d5db",
"background-color": "white",
cursor: "pointer",
}}
>
{props.confirm.cancelLabel ?? "Cancel"}
</button>
<button
onClick={props.onConfirm}
style={{
padding: "8px 16px",
"border-radius": "6px",
border: "none",
"background-color": isDanger() ? "#dc2626" : "#3b82f6",
color: "white",
cursor: "pointer",
}}
>
{props.confirm.confirmLabel ?? "Confirm"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import { createContext, useContext, type ParentProps } from "solid-js";
export interface RepeatScopeValue {
item: unknown;
index: number;
basePath: string;
}
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
export function RepeatScopeProvider(props: ParentProps<RepeatScopeValue>) {
return (
<RepeatScopeContext.Provider
value={{ item: props.item, index: props.index, basePath: props.basePath }}
>
{props.children}
</RepeatScopeContext.Provider>
);
}
export function useRepeatScope(): RepeatScopeValue | null {
return useContext(RepeatScopeContext);
}
+215
View File
@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@solidjs/testing-library";
import { createEffect, createSignal, type Accessor, type JSX } from "solid-js";
import { createStateStore } from "@json-render/core";
import {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
} from "./state";
function renderWithState<T>(
hook: () => T,
initialState: Record<string, unknown> = {},
extraProps?: { store?: any; onStateChange?: any },
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div data-testid="test" />) as unknown as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState} {...(extraProps ?? {})}>
<TestComponent />
</StateProvider>
));
return result;
}
describe("StateProvider + hooks", () => {
it("useStateStore() throws outside provider", () => {
expect(() => useStateStore()).toThrow(
"useStateStore must be used within a StateProvider",
);
});
it("StateProvider + useStateStore round-trip (get + set)", () => {
const ctx = renderWithState(() => useStateStore(), { count: 0 });
expect(ctx.get("/count")).toBe(0);
ctx.set("/count", 42);
expect(ctx.get("/count")).toBe(42);
});
it("useStateValue reads from state", () => {
const value = renderWithState(() => useStateValue("/name"), {
name: "Alice",
});
expect(value()).toBe("Alice");
});
it("useStateBinding returns value and setter", () => {
const [value, setValue] = renderWithState(() => useStateBinding("/x"), {
x: 1,
});
expect(value()).toBe(1);
expect(typeof setValue).toBe("function");
});
it("useStateValue stays reactive after state updates", () => {
const observed: Array<string | undefined> = [];
function TestComponent(): JSX.Element {
const value = useStateValue<string>("/name");
createEffect(() => {
observed.push(value());
});
const store = useStateStore();
return (
<button
data-testid="update"
onClick={() => store.set("/name", "Bob")}
/>
) as unknown as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ name: "Alice" }}>
<TestComponent />
</StateProvider>
));
expect(observed).toEqual(["Alice"]);
getByTestId("update").click();
expect(observed).toEqual(["Alice", "Bob"]);
});
it("useStateBinding value accessor stays reactive after state updates", () => {
const observed: Array<number | undefined> = [];
function TestComponent(): JSX.Element {
const [value, setValue] = useStateBinding<number>("/count");
createEffect(() => {
observed.push(value());
});
return (
<button data-testid="update" onClick={() => setValue(2)} />
) as unknown as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ count: 1 }}>
<TestComponent />
</StateProvider>
));
expect(observed).toEqual([1]);
getByTestId("update").click();
expect(observed).toEqual([1, 2]);
});
it("provides initial state to consumers", () => {
const ctx = renderWithState(() => useStateStore(), { a: 1, b: "hello" });
expect(ctx.state).toEqual({ a: 1, b: "hello" });
});
it("provides empty object when no initial state", () => {
const ctx = renderWithState(() => useStateStore());
expect(ctx.state).toEqual({});
});
it("get() retrieves values by path", () => {
const ctx = renderWithState(() => useStateStore(), {
user: { name: "Bob" },
});
expect(ctx.get("/user/name")).toBe("Bob");
});
it("get() returns undefined for missing path", () => {
const ctx = renderWithState(() => useStateStore(), {
user: { name: "Bob" },
});
expect(ctx.get("/user/age")).toBeUndefined();
});
it("set() updates values at path", () => {
const ctx = renderWithState(() => useStateStore(), { x: 0 });
ctx.set("/x", 42);
expect(ctx.get("/x")).toBe(42);
});
it("set() creates nested paths", () => {
const ctx = renderWithState(() => useStateStore());
ctx.set("/a/b/c", "deep");
expect(ctx.get("/a/b/c")).toBe("deep");
});
it("set() calls onStateChange callback", () => {
const onChange = vi.fn();
const ctx = renderWithState(
() => useStateStore(),
{},
{ onStateChange: onChange },
);
ctx.set("/name", "Alice");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
});
it("update() handles multiple values at once", () => {
const ctx = renderWithState(() => useStateStore());
ctx.update({ "/a": 1, "/b": "hello" });
expect(ctx.get("/a")).toBe(1);
expect(ctx.get("/b")).toBe("hello");
});
it("update() calls onStateChange with all changes", () => {
const onChange = vi.fn();
const ctx = renderWithState(
() => useStateStore(),
{},
{ onStateChange: onChange },
);
ctx.update({ "/a": 1, "/b": 2 });
expect(onChange).toHaveBeenCalledOnce();
const [changes] = onChange.mock.calls[0]!;
expect(changes).toEqual(
expect.arrayContaining([
{ path: "/a", value: 1 },
{ path: "/b", value: 2 },
]),
);
});
it("handles deeply nested state paths", () => {
const ctx = renderWithState(() => useStateStore());
ctx.set("/a/b/c/d", "nested");
expect(ctx.get("/a/b/c/d")).toBe("nested");
});
it("controlled mode: reads/writes through external StateStore", () => {
const store = createStateStore({ x: 10 });
const ctx = renderWithState(() => useStateStore(), {}, { store });
expect(ctx.get("/x")).toBe(10);
ctx.set("/x", 99);
expect(store.getSnapshot()).toEqual({ x: 99 });
store.set("/x", 200);
expect(store.get("/x")).toBe(200);
});
});
+208
View File
@@ -0,0 +1,208 @@
import {
createContext,
useContext,
createSignal,
createEffect,
createMemo,
onCleanup,
type Accessor,
type ParentProps,
} from "solid-js";
import {
getByPath,
createStateStore,
type StateModel,
type StateStore,
} from "@json-render/core";
import { flattenToPointers } from "@json-render/core/store-utils";
export interface StateContextValue {
state: StateModel;
get: (path: string) => unknown;
set: (path: string, value: unknown) => void;
update: (updates: Record<string, unknown>) => void;
getSnapshot: () => StateModel;
subscribeChanges: (
listener: (changes: Array<{ path: string; value: unknown }>) => void,
) => () => void;
}
const StateContext = createContext<StateContextValue | null>(null);
export interface StateProviderProps {
store?: StateStore;
initialState?: StateModel;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
function computeInitialFlat(
isControlled: boolean,
initialState: StateModel,
): Record<string, unknown> | null {
if (isControlled) return null;
if (Object.keys(initialState).length === 0) return {};
return flattenToPointers(initialState);
}
export function StateProvider(props: ParentProps<StateProviderProps>) {
let internalStore: StateStore | undefined;
if (!props.store) {
internalStore = createStateStore(props.initialState ?? {});
}
const store = () => props.store ?? internalStore!;
const initialMode = props.store ? "controlled" : "uncontrolled";
let modeWarned = false;
if (
typeof globalThis !== "undefined" &&
(globalThis as any).process?.env?.NODE_ENV !== "production"
) {
createEffect(() => {
const currentMode = props.store ? "controlled" : "uncontrolled";
if (currentMode !== initialMode && !modeWarned) {
modeWarned = true;
console.warn(
`StateProvider: switching from ${initialMode} to ${currentMode} mode is not supported.`,
);
}
});
}
let prevInitialState = props.initialState;
let prevFlat: Record<string, unknown> | null = computeInitialFlat(
!!props.store,
props.initialState ?? {},
);
createEffect(() => {
if (props.store) return;
const initialState = props.initialState ?? {};
if (initialState === prevInitialState) return;
prevInitialState = initialState;
const nextFlat =
initialState && Object.keys(initialState).length > 0
? flattenToPointers(initialState)
: {};
const prevFlatObj = prevFlat ?? {};
const allKeys = new Set([
...Object.keys(prevFlatObj),
...Object.keys(nextFlat),
]);
const updates: Record<string, unknown> = {};
for (const key of allKeys) {
if (prevFlatObj[key] !== nextFlat[key]) {
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
}
}
prevFlat = nextFlat;
if (Object.keys(updates).length > 0) {
store().update(updates);
}
});
const [state, setState] = createSignal<StateModel>(store().getSnapshot(), {
equals: false,
});
const changeListeners = new Set<
(changes: Array<{ path: string; value: unknown }>) => void
>();
const subscribeChanges = (
listener: (changes: Array<{ path: string; value: unknown }>) => void,
) => {
changeListeners.add(listener);
return () => {
changeListeners.delete(listener);
};
};
const notifyChanges = (changes: Array<{ path: string; value: unknown }>) => {
for (const listener of changeListeners) {
listener(changes);
}
};
createEffect(() => {
const s = store();
setState(s.getSnapshot());
const unsubscribe = s.subscribe(() => {
setState(s.getSnapshot());
});
onCleanup(unsubscribe);
});
const set = (path: string, value: unknown) => {
const s = store();
const prev = s.getSnapshot();
const prevValue = getByPath(prev, path);
s.set(path, value);
if (prevValue !== value) {
const changes = [{ path, value }];
notifyChanges(changes);
if (!props.store && s.getSnapshot() !== prev) {
props.onStateChange?.(changes);
}
}
};
const update = (updates: Record<string, unknown>) => {
const s = store();
const prev = s.getSnapshot();
s.update(updates);
const changes: Array<{ path: string; value: unknown }> = [];
for (const [path, value] of Object.entries(updates)) {
if (getByPath(prev, path) !== value) {
changes.push({ path, value });
}
}
if (changes.length > 0) {
notifyChanges(changes);
if (!props.store && s.getSnapshot() !== prev) {
props.onStateChange?.(changes);
}
}
};
const get = (path: string) => store().get(path);
const getSnapshot = () => store().getSnapshot();
const ctx: StateContextValue = {
get state() {
return state();
},
get,
set,
update,
getSnapshot,
subscribeChanges,
};
return (
<StateContext.Provider value={ctx}>{props.children}</StateContext.Provider>
);
}
export function useStateStore(): StateContextValue {
const ctx = useContext(StateContext);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
export function useStateValue<T>(path: string): Accessor<T | undefined> {
const store = useStateStore();
return createMemo(() => getByPath(store.state, path) as T | undefined);
}
export function useStateBinding<T>(
path: string,
): [Accessor<T | undefined>, (value: T) => void] {
const store = useStateStore();
const value = createMemo(() => getByPath(store.state, path) as T | undefined);
const setValue = (newValue: T) => store.set(path, newValue);
return [value, setValue];
}
@@ -0,0 +1,194 @@
import { describe, it, expect } from "vitest";
import { render } from "@solidjs/testing-library";
import { createEffect, type JSX } from "solid-js";
import { StateProvider } from "./state";
import {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
} from "./validation";
function withProviders<T>(
hook: () => T,
initialState: Record<string, unknown> = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
return result;
}
function withStateOnly<T>(hook: () => T): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={{}}>
<TestComponent />
</StateProvider>
));
return result;
}
function withBothContexts(
initialState: Record<string, unknown> = {},
fieldPath: string = "/name",
config?: { checks: Array<{ type: string; message: string }> },
) {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
function TestComponent(): JSX.Element {
validationCtx = useValidation();
fieldCtx = useFieldValidation(fieldPath, config);
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
return { validationCtx, fieldCtx };
}
describe("ValidationProvider — provide/inject", () => {
it("useValidation() throws outside a provider", () => {
expect(() => useValidation()).toThrow(
"useValidation must be used within a ValidationProvider",
);
});
});
describe("useOptionalValidation", () => {
it("returns null outside ValidationProvider", () => {
const result = withStateOnly(() => useOptionalValidation());
expect(result).toBeNull();
});
it("returns context inside ValidationProvider", () => {
const result = withProviders(() => useOptionalValidation());
expect(result).not.toBeNull();
expect(typeof result!.validate).toBe("function");
expect(typeof result!.validateAll).toBe("function");
});
});
describe("useFieldValidation — lifecycle", () => {
it("validate() with empty required field returns valid:false and errors", () => {
const { fieldCtx } = withBothContexts({ name: "" }, "/name", {
checks: [{ type: "required", message: "Name is required" }],
});
const result = fieldCtx.validate();
expect(result.valid).toBe(false);
expect(result.errors).toContain("Name is required");
expect(fieldCtx.errors()).toContain("Name is required");
expect(fieldCtx.isValid()).toBe(false);
});
it("validate() with valid value returns valid:true and no errors", () => {
const { fieldCtx } = withBothContexts({ name: "Alice" }, "/name", {
checks: [{ type: "required", message: "Name is required" }],
});
const result = fieldCtx.validate();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(fieldCtx.errors()).toHaveLength(0);
expect(fieldCtx.isValid()).toBe(true);
});
it("touch() sets touched:true in fieldStates", () => {
const { validationCtx, fieldCtx } = withBothContexts({}, "/email");
fieldCtx.touch();
expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
});
it("clear() resets field state from validation context", () => {
const { validationCtx, fieldCtx } = withBothContexts(
{ email: "" },
"/email",
{ checks: [{ type: "required", message: "Required" }] },
);
fieldCtx.validate();
fieldCtx.clear();
expect(validationCtx.fieldStates["/email"]).toBeUndefined();
});
it("useFieldValidation accessors stay reactive after validate/touch", () => {
const observedErrors: string[][] = [];
const observedTouched: boolean[] = [];
function TestComponent(): JSX.Element {
const field = useFieldValidation("/email", {
checks: [{ type: "required", message: "Required" }],
});
createEffect(() => {
observedErrors.push(field.errors());
});
createEffect(() => {
observedTouched.push(field.state().touched);
});
return (
<>
<button data-testid="validate" onClick={() => field.validate()} />
<button data-testid="touch" onClick={() => field.touch()} />
</>
) as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ email: "" }}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
expect(observedErrors).toEqual([[]]);
expect(observedTouched).toEqual([false]);
getByTestId("validate").click();
expect(observedErrors).toEqual([[], ["Required"]]);
expect(observedTouched).toEqual([false, true]);
getByTestId("touch").click();
expect(observedTouched).toEqual([false, true, true]);
});
});
describe("validateAll", () => {
it("returns true when all registered fields pass", () => {
const { validationCtx } = withBothContexts({ name: "Alice" }, "/name", {
checks: [{ type: "required", message: "Required" }],
});
expect(validationCtx.validateAll()).toBe(true);
});
it("returns false when any field fails", () => {
const { validationCtx } = withBothContexts({ name: "" }, "/name", {
checks: [{ type: "required", message: "Required" }],
});
expect(validationCtx.validateAll()).toBe(false);
});
});
+263
View File
@@ -0,0 +1,263 @@
import {
createContext,
useContext,
createSignal,
createEffect,
createMemo,
type Accessor,
type ParentProps,
} from "solid-js";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
const ValidationContext = createContext<ValidationContextValue | null>(null);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
}
function dynamicArgsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const va = a[key];
const vb = b[key];
if (va === vb) continue;
if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
const sa = (va as Record<string, unknown>).$state;
const sb = (vb as Record<string, unknown>).$state;
if (typeof sa === "string" && sa === sb) continue;
}
return false;
}
return true;
}
function validationConfigEqual(
a: ValidationConfig,
b: ValidationConfig,
): boolean {
if (a === b) return true;
if (a.validateOn !== b.validateOn) return false;
const ac = a.checks ?? [];
const bc = b.checks ?? [];
if (ac.length !== bc.length) return false;
for (let i = 0; i < ac.length; i++) {
const ca = ac[i]!;
const cb = bc[i]!;
if (ca.type !== cb.type) return false;
if (ca.message !== cb.message) return false;
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
}
return true;
}
export function ValidationProvider(
props: ParentProps<ValidationProviderProps>,
) {
const { getSnapshot } = useStateStore();
const customFunctions = () => props.customFunctions ?? {};
const [fieldStates, setFieldStates] = createSignal<
Record<string, FieldValidationState>
>({});
let fieldStatesRef: Record<string, FieldValidationState> = {};
const [fieldConfigs, setFieldConfigs] = createSignal<
Record<string, ValidationConfig>
>({});
const registerField = (path: string, config: ValidationConfig) => {
setFieldConfigs((prev) => {
const existing = prev[path];
if (existing && validationConfigEqual(existing, config)) {
return prev;
}
return { ...prev, [path]: config };
});
};
const validate = (
path: string,
config: ValidationConfig,
): ValidationResult => {
const currentState = getSnapshot();
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: currentState,
customFunctions: customFunctions(),
});
const newFieldState: FieldValidationState = {
touched: fieldStatesRef[path]?.touched ?? true,
validated: true,
result,
};
fieldStatesRef = {
...fieldStatesRef,
[path]: newFieldState,
};
setFieldStates(fieldStatesRef);
return result;
};
const touch = (path: string) => {
fieldStatesRef = {
...fieldStatesRef,
[path]: {
...fieldStatesRef[path],
touched: true,
validated: fieldStatesRef[path]?.validated ?? false,
result: fieldStatesRef[path]?.result ?? null,
},
};
setFieldStates(fieldStatesRef);
};
const clear = (path: string) => {
const { [path]: _, ...rest } = fieldStatesRef;
fieldStatesRef = rest;
setFieldStates(rest);
};
const validateAll = () => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs())) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
};
const value: ValidationContextValue = {
get customFunctions() {
return customFunctions();
},
get fieldStates() {
fieldStates();
return fieldStatesRef;
},
validate,
touch,
clear,
validateAll,
registerField,
};
return (
<ValidationContext.Provider value={value}>
{props.children}
</ValidationContext.Provider>
);
}
export function useValidation(): ValidationContextValue {
const ctx = useContext(ValidationContext);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
export function useOptionalValidation(): ValidationContextValue | null {
return useContext(ValidationContext);
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: Accessor<FieldValidationState>;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: Accessor<string[]>;
isValid: Accessor<boolean>;
} {
const validation = useValidation();
createEffect(() => {
if (path && config) {
validation.registerField(path, config);
}
});
const state = createMemo<FieldValidationState>(() => {
const current = validation.fieldStates[path];
return (
current ?? {
touched: false,
validated: false,
result: null,
}
);
});
const validate = () => validation.validate(path, config ?? { checks: [] });
const touch = () => validation.touch(path);
const clear = () => validation.clear(path);
const errors = createMemo(() => state().result?.errors ?? []);
const isValid = createMemo(() => state().result?.valid ?? true);
return {
state,
validate,
touch,
clear,
errors,
isValid,
};
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { render } from "@solidjs/testing-library";
import type { JSX } from "solid-js";
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
import { StateProvider } from "./state";
function renderWithVisibility<T>(
hook: () => T,
data: Record<string, unknown> = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as unknown as JSX.Element;
}
render(() => (
<StateProvider initialState={data}>
<VisibilityProvider>
<TestComponent />
</VisibilityProvider>
</StateProvider>
));
return result;
}
describe("useVisibility", () => {
it("provides isVisible function", () => {
const ctx = renderWithVisibility(() => useVisibility());
expect(typeof ctx.isVisible).toBe("function");
});
it("provides visibility context with stateModel", () => {
const ctx = renderWithVisibility(() => useVisibility(), { test: true });
expect(ctx.ctx.stateModel).toEqual({ test: true });
});
});
describe("useIsVisible", () => {
it("returns true for undefined condition", () => {
const result = renderWithVisibility(() => useIsVisible(undefined));
expect(result).toBe(true);
});
it("returns true for true condition", () => {
const result = renderWithVisibility(() => useIsVisible(true));
expect(result).toBe(true);
});
it("returns false for false condition", () => {
const result = renderWithVisibility(() => useIsVisible(false));
expect(result).toBe(false);
});
it("evaluates $state conditions against data", () => {
const trueResult = renderWithVisibility(
() => useIsVisible({ $state: "/isVisible" }),
{ isVisible: true },
);
expect(trueResult).toBe(true);
const falseResult = renderWithVisibility(
() => useIsVisible({ $state: "/isVisible" }),
{ isVisible: false },
);
expect(falseResult).toBe(false);
});
it("evaluates equality conditions", () => {
const result = renderWithVisibility(
() => useIsVisible({ $state: "/count", eq: 1 }),
{ count: 1 },
);
expect(result).toBe(true);
});
it("evaluates array conditions (implicit AND)", () => {
const result = renderWithVisibility(
() =>
useIsVisible([
{ $state: "/user/isAdmin" },
{ $state: "/count", eq: 5 },
]),
{ user: { isAdmin: true }, count: 5 },
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,53 @@
import { createContext, useContext, type ParentProps } from "solid-js";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface VisibilityContextValue {
isVisible: (condition: VisibilityCondition | undefined) => boolean;
ctx: CoreVisibilityContext;
}
const VisibilityContext = createContext<VisibilityContextValue | null>(null);
export type VisibilityProviderProps = ParentProps;
export function VisibilityProvider(props: VisibilityProviderProps) {
const stateStore = useStateStore();
const visibilityCtx: CoreVisibilityContext = {
get stateModel() {
return stateStore.state;
},
};
const value: VisibilityContextValue = {
isVisible: (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, visibilityCtx),
ctx: visibilityCtx,
};
return (
<VisibilityContext.Provider value={value}>
{props.children}
</VisibilityContext.Provider>
);
}
export function useVisibility(): VisibilityContextValue {
const ctx = useContext(VisibilityContext);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
export function useIsVisible(
condition: VisibilityCondition | undefined,
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
+908
View File
@@ -0,0 +1,908 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { defineCatalog, type Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
defineRegistry,
type ComponentRenderProps,
} from "./renderer";
import type { ComponentFn } from "./catalog-types";
import { useStateStore } from "./contexts/state";
import { useFieldValidation } from "./contexts/validation";
import { useBoundProp } from "./hooks";
import { schema as solidSchema } from "./schema";
import { z } from "zod";
const exampleCatalog = defineCatalog(solidSchema, {
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
RendererTabs: {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
slots: [],
description: "Badge indicating which renderer is currently active",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: {
params: z.object({}),
description: "Reset the counter to 0",
},
toggleItem: {
params: z.object({ index: z.number() }),
description: "Toggle the completed state of a todo item",
},
switchToVue: {
params: z.object({}),
description: "Switch to the Vue renderer",
},
switchToReact: {
params: z.object({}),
description: "Switch to the React renderer",
},
switchToSvelte: {
params: z.object({}),
description: "Switch to the Svelte renderer",
},
switchToSolid: {
params: z.object({}),
description: "Switch to the Solid renderer",
},
},
});
const definedStack: ComponentFn<typeof exampleCatalog, "Stack"> = (ctx) => (
<div>{ctx.children}</div>
);
const definedButton: ComponentFn<typeof exampleCatalog, "Button"> = (ctx) => (
<button data-testid="defined-btn" onClick={() => ctx.emit("press")}>
{ctx.props.label}
</button>
);
const definedText: ComponentFn<typeof exampleCatalog, "Text"> = (ctx) => (
<span data-testid="defined-text">{ctx.props.content}</span>
);
const definedCard: ComponentFn<typeof exampleCatalog, "Card"> = (ctx) => (
<div>{ctx.children}</div>
);
const definedBadge: ComponentFn<typeof exampleCatalog, "Badge"> = (ctx) => (
<span>{ctx.props.label}</span>
);
const definedListItem: ComponentFn<typeof exampleCatalog, "ListItem"> = (
ctx,
) => <div>{ctx.props.title}</div>;
const definedRendererBadge: ComponentFn<
typeof exampleCatalog,
"RendererBadge"
> = (ctx) => <span>{ctx.props.renderer}</span>;
const definedRendererTabs: ComponentFn<
typeof exampleCatalog,
"RendererTabs"
> = () => <div />;
const exampleComponents = {
Stack: definedStack,
Button: definedButton,
Text: definedText,
Card: definedCard,
Badge: definedBadge,
ListItem: definedListItem,
RendererBadge: definedRendererBadge,
RendererTabs: definedRendererTabs,
};
function Button(props: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => props.emit("press")}>
{props.element.props.label}
</button>
);
}
function Text(props: ComponentRenderProps<{ text: unknown }>) {
const display = () => {
const v = props.element.props.text;
if (v == null) return "";
return typeof v === "string" ? v : JSON.stringify(v);
};
return <span data-testid="text">{display()}</span>;
}
function InputField(
props: ComponentRenderProps<{
label?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
}>,
) {
const elementProps = () => props.element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
elementProps().value as string | undefined,
props.bindings?.value,
);
const [localValue, setLocalValue] = createSignal("");
const isBound = () => !!props.bindings?.value;
const value = () => (isBound() ? (boundValue ?? "") : localValue());
const setValue = (v: string) =>
isBound() ? setBoundValue(v) : setLocalValue(v);
const hasValidation = () =>
!!(props.bindings?.value && elementProps().checks?.length);
const config = () =>
hasValidation() ? { checks: elementProps().checks ?? [] } : undefined;
const { errors } = useFieldValidation(props.bindings?.value ?? "", config());
return (
<div>
{elementProps().label && <label>{elementProps().label}</label>}
<input
data-testid="input"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
/>
{errors().length > 0 && (
<span data-testid="input-error">{errors()[0]}</span>
)}
</div>
);
}
function SelectField(
props: ComponentRenderProps<{ label?: string; value?: string }>,
) {
const [boundValue] = useBoundProp<string>(
props.element.props.value as string | undefined,
props.bindings?.value,
);
return <span data-testid="select-value">{boundValue ?? ""}</span>;
}
function ValidatedSelect(
props: ComponentRenderProps<{
label?: string;
name?: string;
options?: string[];
placeholder?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
validateOn?: "change" | "blur" | "submit";
}>,
) {
const elementProps = () => props.element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
elementProps().value as string | undefined,
props.bindings?.value,
);
const [localValue, setLocalValue] = createSignal("");
const isBound = () => !!props.bindings?.value;
const value = () => (isBound() ? (boundValue ?? "") : localValue());
const setValue = (v: string) =>
isBound() ? setBoundValue(v) : setLocalValue(v);
const validateOn = () => elementProps().validateOn ?? "change";
const hasValidation = () =>
!!(props.bindings?.value && elementProps().checks?.length);
const config = () =>
hasValidation()
? { checks: elementProps().checks ?? [], validateOn: validateOn() }
: undefined;
const { errors, validate } = useFieldValidation(
props.bindings?.value ?? "",
config(),
);
const options = () => elementProps().options ?? [];
const name = () => elementProps().name ?? "default";
return (
<div>
{elementProps().label && <label>{elementProps().label}</label>}
<select
data-testid={`select-${name()}`}
value={value()}
onChange={(e) => {
setValue(e.currentTarget.value);
if (hasValidation() && validateOn() === "change") validate();
props.emit("change");
}}
>
<option value="">{elementProps().placeholder ?? "Select..."}</option>
{options().map((opt) => (
<option value={opt}>{opt}</option>
))}
</select>
{errors().length > 0 && (
<span data-testid={`select-error-${name()}`}>{errors()[0]}</span>
)}
</div>
);
}
function Stack(props: ComponentRenderProps<Record<string, unknown>>) {
return <div data-testid="stack">{props.children}</div>;
}
let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
function StateProbe() {
const ctx = useStateStore();
probeGetSnapshot = ctx.getSnapshot;
return <div data-testid="state-probe" />;
}
const registry = { Button, Text, Input: InputField, Select: SelectField };
function getState(): Record<string, unknown> {
return probeGetSnapshot!();
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
render(() => (
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={functions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// $template expressions in rendering
// =============================================================================
describe("$template expressions in rendering", () => {
it("interpolates state values into a template string", () => {
const spec: Spec = {
state: { user: { name: "Alice" }, count: 3 },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$template: "Hello, ${/user/name}! You have ${/count} messages.",
},
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe(
"Hello, Alice! You have 3 messages.",
);
});
it("resolves missing paths to empty string", () => {
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $template: "Hi ${/name}!" },
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("Hi !");
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
probeGetSnapshot = undefined;
const loadCities = vi.fn(async (_params: Record<string, unknown>) => {});
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
render(() => (
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ loadCities }}
>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
expect(loadCities).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
expect(loadCities).toHaveBeenCalledTimes(1);
});
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
probeGetSnapshot = undefined;
const action1 = vi.fn();
const action2 = vi.fn();
const reg = { ...registry, Stack };
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ action1, action2 }}
>
<Renderer spec={spec} registry={reg} />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
});
describe("defineRegistry reactivity", () => {
it("updates $state-backed props after setState actions", async () => {
const { registry: definedRegistry } = defineRegistry(exampleCatalog, {
components: exampleComponents,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
switchToSvelte: async () => {},
switchToSolid: async () => {},
},
});
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "text"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: {
action: "setState",
params: { statePath: "/value", value: "b" },
},
},
children: [],
},
text: {
type: "Text",
props: { content: { $state: "/value" } },
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={definedRegistry} initialState={spec.state}>
<Renderer spec={spec} registry={definedRegistry} />
</JSONUIProvider>
));
expect(screen.getByTestId("defined-text").textContent).toBe("a");
fireEvent.click(screen.getByTestId("defined-btn"));
await waitFor(() => {
expect(screen.getByTestId("defined-text").textContent).toBe("b");
});
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false } when a required field is empty", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
});
it("writes { valid: true } when all fields pass validation", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.result).toEqual({ valid: true, errors: {} });
});
});
it("defaults to /formValidation when no statePath is provided", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
});
// =============================================================================
// Select validate-on-change timing (#151)
// =============================================================================
describe("Select validate-on-change sees the new value, not the stale value", () => {
const regWithSelect = {
...registry,
Stack,
Select: ValidatedSelect,
};
it("does not show 'required' error when selecting the first value", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { form: { country: "" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["countrySelect"],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.change(screen.getByTestId("select-country"), {
target: { value: "US" },
});
await waitFor(() => {
const state = getState();
expect((state.form as Record<string, unknown>).country).toBe("US");
});
expect(screen.queryByTestId("select-error-country")).toBeNull();
});
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: {
form: { country: "US", city: "" },
availableCities: ["New York", "Chicago"],
},
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["citySelect"],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: ["New York", "Chicago"],
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.change(screen.getByTestId("select-city"), {
target: { value: "New York" },
});
await waitFor(() => {
const state = getState();
expect((state.form as Record<string, unknown>).city).toBe("New York");
});
expect(screen.queryByTestId("select-error-city")).toBeNull();
});
});
+430
View File
@@ -0,0 +1,430 @@
import { describe, it, expect } from "vitest";
import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks";
describe("flatToTree", () => {
it("converts array of elements to tree structure", () => {
const elements = [
{ key: "container", type: "stack", props: {}, parentKey: null },
{
key: "text1",
type: "text",
props: { content: "Hello" },
parentKey: "container",
},
{
key: "text2",
type: "text",
props: { content: "World" },
parentKey: "container",
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("container");
expect(Object.keys(tree.elements)).toHaveLength(3);
expect(tree.elements["container"]).toBeDefined();
expect(tree.elements["text1"]).toBeDefined();
expect(tree.elements["text2"]).toBeDefined();
});
it("builds parent-child relationships", () => {
const elements = [
{ key: "root", type: "stack", props: {}, parentKey: null },
{ key: "child1", type: "text", props: {}, parentKey: "root" },
{ key: "child2", type: "text", props: {}, parentKey: "root" },
];
const tree = flatToTree(elements);
expect(tree.elements["root"]?.children).toHaveLength(2);
expect(tree.elements["root"]?.children).toContain("child1");
expect(tree.elements["root"]?.children).toContain("child2");
});
it("handles single root element", () => {
const elements = [
{
key: "only",
type: "text",
props: { content: "Single" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("only");
expect(Object.keys(tree.elements)).toHaveLength(1);
});
it("handles deeply nested elements", () => {
const elements = [
{ key: "level0", type: "stack", props: {}, parentKey: null },
{ key: "level1", type: "stack", props: {}, parentKey: "level0" },
{ key: "level2", type: "stack", props: {}, parentKey: "level1" },
{ key: "level3", type: "text", props: {}, parentKey: "level2" },
];
const tree = flatToTree(elements);
expect(tree.root).toBe("level0");
expect(tree.elements["level0"]?.children).toContain("level1");
expect(tree.elements["level1"]?.children).toContain("level2");
expect(tree.elements["level2"]?.children).toContain("level3");
});
it("preserves element props", () => {
const elements = [
{
key: "btn",
type: "button",
props: { label: "Click me", variant: "primary" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.elements["btn"]?.props).toEqual({
label: "Click me",
variant: "primary",
});
});
it("preserves visibility conditions", () => {
const elements = [
{
key: "conditional",
type: "text",
props: {},
parentKey: null,
visible: { $state: "/isVisible" },
},
];
const tree = flatToTree(elements);
expect(tree.elements["conditional"]?.visible).toEqual({
$state: "/isVisible",
});
});
it("handles elements with undefined parentKey as root", () => {
const elements = [
{ key: "root", type: "stack", props: {} } as {
key: string;
type: string;
props: Record<string, unknown>;
parentKey?: string | null;
},
];
const tree = flatToTree(elements);
// Elements without parentKey should not become root (only null parentKey)
// This tests the edge case
expect(tree.elements["root"]).toBeDefined();
});
it("handles empty elements array", () => {
const tree = flatToTree([]);
expect(tree.root).toBe("");
expect(Object.keys(tree.elements)).toHaveLength(0);
});
it("handles multiple children correctly", () => {
const elements = [
{ key: "parent", type: "grid", props: {}, parentKey: null },
{ key: "a", type: "card", props: {}, parentKey: "parent" },
{ key: "b", type: "card", props: {}, parentKey: "parent" },
{ key: "c", type: "card", props: {}, parentKey: "parent" },
{ key: "d", type: "card", props: {}, parentKey: "parent" },
];
const tree = flatToTree(elements);
expect(tree.elements["parent"]?.children).toHaveLength(4);
expect(tree.elements["parent"]?.children).toEqual(["a", "b", "c", "d"]);
});
});
// =============================================================================
// buildSpecFromParts
// =============================================================================
describe("buildSpecFromParts", () => {
it("returns null when no data-spec parts are present", () => {
const parts = [
{ type: "text", text: "Hello there" },
{ type: "text", text: "How can I help?" },
];
expect(buildSpecFromParts(parts)).toBeNull();
});
it("builds a spec from patch parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Card", props: { title: "Hello" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
expect(spec!.elements.main).toEqual({
type: "Card",
props: { title: "Hello" },
children: [],
});
});
it("handles flat spec parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": { type: "Card", props: {}, children: [] },
},
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("card-1");
expect(spec!.elements["card-1"]).toBeDefined();
});
it("ignores non-spec parts", () => {
const parts = [
{ type: "text", text: "Some text" },
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{ type: "tool-invocation", data: { toolName: "search" } },
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
});
it("applies patches incrementally", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Stack", props: {}, children: ["child"] },
},
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/child",
value: { type: "Text", props: { content: "Hi" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(Object.keys(spec!.elements)).toHaveLength(2);
expect(spec!.elements.child!.props.content).toBe("Hi");
});
it("handles nested spec parts via nestedToFlat", () => {
const parts = [
{
type: "data-spec",
data: {
type: "nested",
spec: {
type: "Card",
props: { title: "Nested" },
children: [
{ type: "Text", props: { content: "Child" }, children: [] },
],
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBeTruthy();
// nestedToFlat generates keys like el-0, el-1
const elementKeys = Object.keys(spec!.elements);
expect(elementKeys.length).toBe(2);
const rootEl = spec?.elements[spec.root];
expect(rootEl).toBeDefined();
expect(rootEl?.type).toBe("Card");
expect(rootEl?.props.title).toBe("Nested");
expect(rootEl?.children).toHaveLength(1);
const childKey = rootEl?.children?.[0];
const childEl = childKey ? spec?.elements[childKey] : undefined;
expect(childEl).toBeDefined();
expect(childEl?.type).toBe("Text");
expect(childEl?.props.content).toBe("Child");
});
it("handles mixed patch + flat + nested parts in sequence", () => {
const parts = [
// Start with a patch
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Stack", props: {}, children: [] },
},
},
},
// Then a flat spec overwrites everything
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Flat" },
children: [],
},
},
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
// Flat overwrites root and elements
expect(spec!.root).toBe("card-1");
expect(spec!.elements["card-1"]).toBeDefined();
expect(spec!.elements["card-1"]!.type).toBe("Card");
});
it("returns empty elements map from empty parts list", () => {
const spec = buildSpecFromParts([]);
expect(spec).toBeNull();
});
});
// =============================================================================
// getTextFromParts
// =============================================================================
describe("getTextFromParts", () => {
it("extracts text from text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("returns empty string when no text parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "x" },
},
},
];
expect(getTextFromParts(parts)).toBe("");
});
it("ignores non-text parts", () => {
const parts = [
{ type: "text", text: "Before" },
{ type: "data-spec", data: {} },
{ type: "tool-invocation", data: {} },
{ type: "text", text: "After" },
];
expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
});
it("trims whitespace from text parts", () => {
const parts = [
{ type: "text", text: " Hello " },
{ type: "text", text: " World " },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("skips empty text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: " " },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("ignores text parts with non-string text field", () => {
const parts = [
{ type: "text", text: "Valid" },
{ type: "text", text: undefined as unknown as string },
{ type: "text", text: 42 as unknown as string },
{ type: "text", text: "Also valid" },
];
expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
});
});
+881
View File
@@ -0,0 +1,881 @@
import { createSignal, onCleanup, createMemo } from "solid-js";
import type {
Spec,
UIElement,
FlatElement,
JsonPatch,
SpecDataPart,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
createMixedStreamParser,
applySpecPatch,
nestedToFlat,
SPEC_DATA_PART_TYPE,
} from "@json-render/core";
/**
* Token usage metadata from AI generation
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
/**
* Parse result for a single line -- either a patch or usage metadata
*/
type ParsedLine =
| { type: "patch"; patch: JsonPatch }
| { type: "usage"; usage: TokenUsage }
| null;
/**
* Parse a single JSON line (patch or metadata)
*/
function parseLine(line: string): ParsedLine {
try {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) {
return null;
}
const parsed = JSON.parse(trimmed);
// Check for usage metadata
if (parsed.__meta === "usage") {
return {
type: "usage",
usage: {
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
},
};
}
return { type: "patch", patch: parsed as JsonPatch };
} catch {
return null;
}
}
/**
* Set a value at a spec path (for add/replace operations).
*/
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
if (path === "/root") {
newSpec.root = value as string;
return;
}
if (path === "/state") {
newSpec.state = value as Record<string, unknown>;
return;
}
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
const statePath = path.slice("/state".length); // e.g. "/posts"
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
newSpec.elements[elementKey] = value as UIElement;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
propPath,
value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Remove a value at a spec path.
*/
function removeSpecValue(newSpec: Spec, path: string): void {
if (path === "/state") {
delete newSpec.state;
return;
}
if (path.startsWith("/state/") && newSpec.state) {
const statePath = path.slice("/state".length);
removeByPath(newSpec.state as Record<string, unknown>, statePath);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
const { [elementKey]: _, ...rest } = newSpec.elements;
newSpec.elements = rest;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
removeByPath(
newElement as unknown as Record<string, unknown>,
propPath,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Get a value at a spec path.
*/
function getSpecValue(spec: Spec, path: string): unknown {
if (path === "/root") return spec.root;
if (path === "/state") return spec.state;
if (path.startsWith("/state/") && spec.state) {
const statePath = path.slice("/state".length);
return getByPath(spec.state as Record<string, unknown>, statePath);
}
return getByPath(spec as unknown as Record<string, unknown>, path);
}
/**
* Apply an RFC 6902 JSON patch to the current spec.
* Supports add, remove, replace, move, copy, and test operations.
*/
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace": {
setSpecValue(newSpec, patch.path, patch.value);
break;
}
case "remove": {
removeSpecValue(newSpec, patch.path);
break;
}
case "move": {
if (!patch.from) break;
const moveValue = getSpecValue(newSpec, patch.from);
removeSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, moveValue);
break;
}
case "copy": {
if (!patch.from) break;
const copyValue = getSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, copyValue);
break;
}
case "test": {
// test is a no-op for rendering purposes (validation only)
break;
}
}
return newSpec;
}
/**
* Options for useUIStream
*/
export interface UseUIStreamOptions {
/** API endpoint */
api: string;
/** Callback when complete */
onComplete?: (spec: Spec) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useUIStream
*/
export interface UseUIStreamReturn {
/** Current UI spec */
readonly spec: Spec | null;
/** Whether currently streaming */
readonly isStreaming: boolean;
/** Error if any */
readonly error: Error | null;
/** Token usage from the last generation */
readonly usage: TokenUsage | null;
/** Raw JSONL lines received from the stream (JSON patch lines) */
readonly rawLines: string[];
/** Send a prompt to generate UI */
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
/** Clear the current spec */
clear: () => void;
}
/**
* Hook for streaming UI generation
*/
export function useUIStream(options: UseUIStreamOptions): UseUIStreamReturn {
const [spec, setSpec] = createSignal<Spec | null>(null);
const [isStreaming, setIsStreaming] = createSignal(false);
const [error, setError] = createSignal<Error | null>(null);
const [usage, setUsage] = createSignal<TokenUsage | null>(null);
const [rawLines, setRawLines] = createSignal<string[]>([]);
let abortController: AbortController | null = null;
const clear = () => {
setSpec(null);
setError(null);
};
const send = async (prompt: string, context?: Record<string, unknown>) => {
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
setIsStreaming(true);
setError(null);
setUsage(null);
setRawLines([]);
// Start with previous spec if provided, otherwise empty spec
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
: { root: "", elements: {} };
setSpec(currentSpec);
try {
const response = await fetch(options.api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
currentSpec,
}),
signal: abortController.signal,
});
if (!response.ok) {
// Try to parse JSON error response for better error messages
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors, use default message
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const result = parseLine(trimmed);
if (!result) continue;
if (result.type === "usage") {
setUsage(result.usage);
} else {
setRawLines((prev) => [...prev, trimmed]);
currentSpec = applyPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
}
// Process any remaining buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
if (result) {
if (result.type === "usage") {
setUsage(result.usage);
} else {
setRawLines((prev) => [...prev, trimmed]);
currentSpec = applyPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
}
options.onComplete?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
setError(resolvedError);
options.onError?.(resolvedError);
} finally {
setIsStreaming(false);
}
};
// Cleanup on disposal
onCleanup(() => {
abortController?.abort();
});
return {
get spec() {
return spec();
},
get isStreaming() {
return isStreaming();
},
get error() {
return error();
},
get usage() {
return usage();
},
get rawLines() {
return rawLines();
},
send,
clear,
};
}
/**
* Convert a flat element list to a Spec.
* Input elements use key/parentKey to establish identity and relationships.
* Output spec uses the map-based format where key is the map entry key
* and parent-child relationships are expressed through children arrays.
*/
export function flatToTree(elements: FlatElement[]): Spec {
const elementMap: Record<string, UIElement> = {};
let root = "";
// First pass: add all elements to map
for (const element of elements) {
elementMap[element.key] = {
type: element.type,
props: element.props,
children: [],
visible: element.visible,
};
}
// Second pass: build parent-child relationships
for (const element of elements) {
if (element.parentKey) {
const parent = elementMap[element.parentKey];
if (parent) {
if (!parent.children) {
parent.children = [];
}
parent.children.push(element.key);
}
} else {
root = element.key;
}
}
return { root, elements: elementMap };
}
// =============================================================================
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
// =============================================================================
// Re-export useStateStore access for useBoundProp without circular import
import { useStateStore as useStateStoreFromContext } from "./contexts/state";
/**
* Hook for two-way bound props. Returns `[value, setValue]` where:
*
* - `value` is the already-resolved prop value (passed through from render props)
* - `setValue` writes back to the bound state path (no-op if not bound)
*
* Designed to work with the `bindings` map that the renderer provides when
* a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
*
* @example
* ```tsx
* import { useBoundProp } from "@json-render/solid";
*
* const Input: ComponentRenderer = (ctx) => {
* const [value, setValue] = useBoundProp<string>(ctx.props.value, ctx.bindings?.value);
* return <input value={value ?? ""} onInput={(e) => setValue(e.target.value)} />;
* };
* ```
*/
export function useBoundProp<T>(
propValue: T | undefined,
bindingPath: string | undefined,
): [T | undefined, (value: T) => void] {
const { set } = useStateStoreFromContext();
const setValue = (value: T) => {
if (bindingPath) set(bindingPath, value);
};
return [propValue, setValue];
}
// =============================================================================
// buildSpecFromParts — Derive Spec from AI SDK data parts
// =============================================================================
/**
* A single part from the AI SDK's `message.parts` array. This is a minimal
* structural type so that library helpers do not depend on the AI SDK.
* Fields are optional because different part types carry different data:
* - Text parts have `text`
* - Data parts have `data`
*/
export interface DataPart {
type: string;
text?: string;
data?: unknown;
}
/**
* Build a `Spec` by replaying all spec data parts from a message's
* parts array. Returns `null` if no spec data parts are present.
*
* This function is designed to work with the AI SDK's `UIMessage.parts` array.
* It picks out parts whose `type` is {@link SPEC_DATA_PART_TYPE} and processes them based
* on the payload's `type` discriminator:
*
* - `"patch"`: Applies the JSON Patch operation incrementally via `applySpecPatch`.
* - `"flat"`: Replaces the spec with the complete flat spec.
* - `"nested"`: Assigns the nested spec directly (future: nested-to-flat conversion).
*
* The function has no AI SDK dependency -- it operates on a generic array of
* `{ type: string; data: unknown }` objects.
*
* @example
* ```tsx
* const spec = buildSpecFromParts(message.parts);
* if (spec) {
* return <MyRenderer spec={spec} />;
* }
* ```
*/
/**
* Type guard that validates a data part payload looks like a valid
* {@link SpecDataPart} before we cast it. Returns `false` (and the
* part is silently skipped) for malformed payloads.
*/
function isSpecDataPart(data: unknown): data is SpecDataPart {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
switch (obj.type) {
case "patch":
return typeof obj.patch === "object" && obj.patch !== null;
case "flat":
case "nested":
return typeof obj.spec === "object" && obj.spec !== null;
default:
return false;
}
}
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
const spec: Spec = { root: "", elements: {} };
let hasSpec = false;
for (const part of parts) {
if (part.type === SPEC_DATA_PART_TYPE) {
if (!isSpecDataPart(part.data)) continue;
const payload = part.data;
if (payload.type === "patch") {
hasSpec = true;
applySpecPatch(spec, payload.patch);
} else if (payload.type === "flat") {
hasSpec = true;
Object.assign(spec, payload.spec);
} else if (payload.type === "nested") {
hasSpec = true;
const flat = nestedToFlat(payload.spec);
Object.assign(spec, flat);
}
}
}
return hasSpec ? spec : null;
}
/**
* Extract and join all text content from a message's parts array.
*
* Filters for parts with `type === "text"`, trims each one, and joins them
* with double newlines so that text from separate agent steps renders as
* distinct paragraphs in markdown.
*
* Has no AI SDK dependency -- operates on a generic `DataPart[]`.
*
* @example
* ```tsx
* const text = getTextFromParts(message.parts);
* if (text) {
* return <Streamdown>{text}</Streamdown>;
* }
* ```
*/
export function getTextFromParts(parts: DataPart[]): string {
return parts
.filter(
(p): p is DataPart & { text: string } =>
p.type === "text" && typeof p.text === "string",
)
.map((p) => p.text.trim())
.filter(Boolean)
.join("\n\n");
}
// =============================================================================
// useJsonRenderMessage — extract spec + text from message parts
// =============================================================================
/**
* Hook that extracts both the json-render spec and text content from a
* message's parts array. Combines `buildSpecFromParts` and `getTextFromParts`
* into a single call with memoized results.
*
* **Memoization behavior:** Results are recomputed only when the `parts`
* accessor returns a new array reference with a different length or last
* element. This is optimized for the typical AI SDK streaming pattern where
* parts are appended incrementally.
*
* @example
* ```tsx
* import { useJsonRenderMessage } from "@json-render/solid";
*
* function MessageBubble(props: { parts: DataPart[] }) {
* const msg = useJsonRenderMessage(() => props.parts);
*
* return (
* <div>
* {msg.text && <Markdown>{msg.text}</Markdown>}
* {msg.hasSpec && <MyRenderer spec={msg.spec!} />}
* </div>
* );
* }
* ```
*/
export function useJsonRenderMessage(getParts: () => DataPart[]) {
const result = createMemo(() => {
const parts = getParts();
return {
spec: buildSpecFromParts(parts),
text: getTextFromParts(parts),
};
});
return {
get spec() {
return result().spec;
},
get text() {
return result().text;
},
get hasSpec() {
const s = result().spec;
return s !== null && Object.keys(s.elements || {}).length > 0;
},
};
}
// =============================================================================
// useChatUI — Chat + GenUI hook
// =============================================================================
/**
* A single message in the chat, which may contain text, a rendered UI spec, or both.
*/
export interface ChatMessage {
/** Unique message ID */
id: string;
/** Who sent this message */
role: "user" | "assistant";
/** Text content (conversational prose) */
text: string;
/** json-render Spec built from JSONL patches (null if no UI was generated) */
spec: Spec | null;
}
/**
* Options for useChatUI
*/
export interface UseChatUIOptions {
/** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
api: string;
/** Callback when streaming completes for a message */
onComplete?: (message: ChatMessage) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useChatUI
*/
export interface UseChatUIReturn {
/** All messages in the conversation */
readonly messages: ChatMessage[];
/** Whether currently streaming an assistant response */
readonly isStreaming: boolean;
/** Error from the last request, if any */
readonly error: Error | null;
/** Send a user message */
send: (text: string) => Promise<void>;
/** Clear all messages and reset the conversation */
clear: () => void;
}
let chatMessageIdCounter = 0;
function generateChatId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
chatMessageIdCounter += 1;
return `msg-${Date.now()}-${chatMessageIdCounter}`;
}
/**
* Hook for chat + GenUI experiences.
*
* Manages a multi-turn conversation where each assistant message can contain
* both conversational text and a json-render UI spec. The hook sends the full
* message history to the API endpoint, reads the streamed response, and
* separates text lines from JSONL patch lines using `createMixedStreamParser`.
*
* @example
* ```tsx
* const chat = useChatUI({ api: "/api/chat" });
*
* // Send a message
* await chat.send("Compare weather in NYC and Tokyo");
*
* // Render messages
* <For each={chat.messages}>
* {(msg) => (
* <div>
* {msg.text && <p>{msg.text}</p>}
* {msg.spec && <MyRenderer spec={msg.spec} />}
* </div>
* )}
* </For>
* ```
*/
export function useChatUI(options: UseChatUIOptions): UseChatUIReturn {
const [messages, setMessages] = createSignal<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = createSignal(false);
const [error, setError] = createSignal<Error | null>(null);
let abortController: AbortController | null = null;
const clear = () => {
setMessages([]);
setError(null);
};
const send = async (text: string) => {
if (!text.trim()) return;
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
const userMessage: ChatMessage = {
id: generateChatId(),
role: "user",
text: text.trim(),
spec: null,
};
const assistantId = generateChatId();
const assistantMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: "",
spec: null,
};
// Append user message and empty assistant placeholder
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsStreaming(true);
setError(null);
// Build messages array for the API (full conversation history + new message).
// Read current messages signal for latest state (avoids stale closure).
const historyForApi = [
...messages()
.filter((m) => m.id !== userMessage.id && m.id !== assistantId)
.map((m) => ({
role: m.role,
content: m.text,
})),
{ role: "user" as const, content: text.trim() },
];
// Mutable state for accumulating the assistant response
let accumulatedText = "";
let currentSpec: Spec = { root: "", elements: {} };
let hasSpec = false;
try {
const response = await fetch(options.api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: historyForApi }),
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
// Use createMixedStreamParser to classify lines
const parser = createMixedStreamParser({
onPatch(patch) {
hasSpec = true;
applySpecPatch(currentSpec, patch);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
spec: {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state
? { state: { ...currentSpec.state } }
: {}),
},
}
: m,
),
);
},
onText(line) {
accumulatedText += (accumulatedText ? "\n" : "") + line;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, text: accumulatedText } : m,
),
);
},
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.flush();
// Build final message for onComplete callback
const finalMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: accumulatedText,
spec: hasSpec
? {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
}
: null,
};
options.onComplete?.(finalMessage);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
setError(resolvedError);
// Remove empty assistant message on error
setMessages((prev) =>
prev.filter((m) => m.id !== assistantId || m.text.length > 0),
);
options.onError?.(resolvedError);
} finally {
setIsStreaming(false);
}
};
// Cleanup on disposal
onCleanup(() => {
abortController?.abort();
});
return {
get messages() {
return messages();
},
get isStreaming() {
return isStreaming();
},
get error() {
return error();
},
send,
clear,
};
}
+109
View File
@@ -0,0 +1,109 @@
// Contexts
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
type StateContextValue,
type StateProviderProps,
} from "./contexts/state";
export {
VisibilityProvider,
useVisibility,
useIsVisible,
type VisibilityContextValue,
type VisibilityProviderProps,
} from "./contexts/visibility";
export {
ActionProvider,
useActions,
useAction,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./contexts/actions";
export {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./contexts/validation";
export {
RepeatScopeProvider,
useRepeatScope,
type RepeatScopeValue,
} from "./contexts/repeat-scope";
// Schema (Solid's spec format)
export {
schema,
type SolidSchema,
type SolidSpec,
// Backward compatibility
elementTreeSchema,
type ElementTreeSchema,
type ElementTreeSpec,
} from "./schema";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Solid
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types";
// Renderer
export {
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Low-level
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
// Hooks
export {
useUIStream,
useChatUI,
useBoundProp,
flatToTree,
buildSpecFromParts,
getTextFromParts,
useJsonRenderMessage,
type UseUIStreamOptions,
type UseUIStreamReturn,
type UseChatUIOptions,
type UseChatUIReturn,
type ChatMessage,
type DataPart,
type TokenUsage,
} from "./hooks";
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { Renderer } from "./renderer";
describe("Renderer", () => {
it("is a valid component function", () => {
expect(typeof Renderer).toBe("function");
});
it("accepts null spec", () => {
const props = {
spec: null,
registry: {},
};
expect(props.spec).toBeNull();
expect(props.registry).toEqual({});
});
it("accepts spec without root", () => {
const props = {
spec: { root: "", elements: {} },
registry: {},
};
expect(props.spec.root).toBe("");
expect(props.spec.elements).toEqual({});
});
it("accepts loading prop", () => {
const props = {
spec: null,
registry: {},
loading: true,
};
expect(props.loading).toBe(true);
});
it("accepts fallback prop", () => {
const Fallback = () => {
const el = document.createElement("div");
el.textContent = "Unknown component";
return el;
};
const props = {
spec: null,
registry: {},
fallback: Fallback,
};
expect(props.fallback).toBe(Fallback);
});
});
+811
View File
@@ -0,0 +1,811 @@
import {
createContext,
useContext,
createMemo,
createEffect,
onCleanup,
ErrorBoundary,
Show,
For,
type JSX,
type Component,
} from "solid-js";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
SchemaDefinition,
StateStore,
ComputedFunction,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetState,
StateModel,
CatalogHasActions,
EventHandle,
} from "./catalog-types";
import { useIsVisible, useVisibility } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useStateStore } from "./contexts/state";
import { StateProvider } from "./contexts/state";
import { VisibilityProvider } from "./contexts/visibility";
import { ActionProvider } from "./contexts/actions";
import { ValidationProvider } from "./contexts/validation";
import { ConfirmDialog } from "./contexts/actions";
import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
/**
* Props passed to component renderers
*/
export interface ComponentRenderProps<P = Record<string, unknown>> {
/** The element being rendered */
element: UIElement<string, P>;
/** Rendered children */
children?: JSX.Element;
/** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
emit: (event: string) => void;
/** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
* Only present when at least one prop uses `{ $bindState: "..." }` or `{ $bindItem: "..." }`.
*/
bindings?: Record<string, string>;
/** Whether the parent is loading */
loading?: boolean;
}
/**
* Component renderer type
*/
export type ComponentRenderer<P = Record<string, unknown>> = Component<
ComponentRenderProps<P>
>;
/**
* Registry of component renderers
*/
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
/**
* Props for the Renderer component
*/
export interface RendererProps {
/** The UI spec to render */
spec: Spec | null;
/** Component registry */
registry: ComponentRegistry;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
fallback?: ComponentRenderer;
}
// ---------------------------------------------------------------------------
// FunctionsContext – provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FunctionsContext =
createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
function useFunctions(): Record<string, ComputedFunction> {
return useContext(FunctionsContext) ?? EMPTY_FUNCTIONS;
}
interface ElementRendererProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
/**
* Element renderer component.
*/
function ElementRenderer(props: ElementRendererProps) {
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const stateStore = useStateStore();
const getSnapshot = () => stateStore.getSnapshot();
const functions = useFunctions();
// Build context with repeat scope and $computed functions
const fullCtx = createMemo<PropResolutionContext>(() => {
const repeatItem = repeatScope?.item;
const repeatIndex = repeatScope?.index;
const repeatBasePath = repeatScope?.basePath;
return {
get stateModel() {
return ctx.stateModel;
},
...(repeatItem !== undefined ? { repeatItem } : {}),
...(repeatIndex !== undefined ? { repeatIndex } : {}),
...(repeatBasePath !== undefined ? { repeatBasePath } : {}),
functions,
};
});
// Evaluate visibility (now supports $item/$index inside repeat scopes)
const isVisible = createMemo(() =>
props.element.visible === undefined
? true
: evaluateVisibility(props.element.visible, fullCtx()),
);
// Create emit function that resolves events to action bindings.
const emit = async (eventName: string) => {
const onBindings = props.element.on;
const binding = onBindings?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
await execute(b);
continue;
}
// Build a fresh context with live store state so that $state
// references in later actions see mutations from earlier ones.
const liveCtx: PropResolutionContext = {
...fullCtx(),
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
};
// Create on() function that returns an EventHandle with metadata for a specific event.
const on = (eventName: string): EventHandle => {
const onBindings = props.element.on;
const binding = onBindings?.[eventName];
if (!binding) {
return { emit: () => {}, shouldPreventDefault: false, bound: false };
}
const actionBindings = Array.isArray(binding) ? binding : [binding];
const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
return {
emit: () => emit(eventName),
shouldPreventDefault,
bound: true,
};
};
// Watch effect: fire actions when watched state paths change.
createEffect(() => {
const watchConfig = props.element.watch;
if (!watchConfig) return;
const paths = Object.keys(watchConfig);
if (paths.length === 0) return;
const unsubscribe = stateStore.subscribeChanges((changes) => {
const changedPaths = new Set(changes.map((change) => change.path));
void (async () => {
for (const path of paths) {
if (!changedPaths.has(path)) continue;
const binding = watchConfig[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
for (const b of bindings) {
if (!b.params) {
await execute(b);
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx(),
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
}
})().catch(console.error);
});
onCleanup(() => {
unsubscribe();
});
});
// Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
const elementBindings = createMemo(() => {
const rawProps = props.element.props as Record<string, unknown>;
return resolveBindings(rawProps, fullCtx());
});
// Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
const resolvedElement = createMemo(() => {
const rawProps = props.element.props as Record<string, unknown>;
const resolvedProps = resolveElementProps(rawProps, fullCtx());
return resolvedProps !== props.element.props
? { ...props.element, props: resolvedProps }
: props.element;
});
return (
<Show when={isVisible()}>
<ErrorBoundary
fallback={(err) => {
console.error(
`[json-render] Rendering error in <${props.element.type}>:`,
err,
);
return null;
}}
>
<ElementRendererContent
resolvedElement={resolvedElement()}
spec={props.spec}
registry={props.registry}
loading={props.loading}
fallback={props.fallback}
emit={emit}
on={on}
bindings={elementBindings()}
/>
</ErrorBoundary>
</Show>
);
}
interface ElementRendererContentProps {
resolvedElement: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
}
/**
* Inner content renderer, separated so it can be wrapped in Show + ErrorBoundary.
*/
function ElementRendererContent(props: ElementRendererContentProps) {
// Get the component renderer
const Comp = () =>
props.registry[props.resolvedElement.type] ?? props.fallback;
return (
<Show
when={Comp()}
fallback={(() => {
console.warn(
`No renderer for component type: ${props.resolvedElement.type}`,
);
return null;
})()}
>
{(ComponentFn) => {
const Component = ComponentFn();
// Render children (with repeat support)
const children = () =>
props.resolvedElement.repeat ? (
<RepeatChildren
element={props.resolvedElement}
spec={props.spec}
registry={props.registry}
loading={props.loading}
fallback={props.fallback}
/>
) : (
<For each={props.resolvedElement.children ?? []}>
{(childKey) => {
const childElement = () => props.spec.elements[childKey];
return (
<Show
when={childElement()}
fallback={(() => {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${props.resolvedElement.type}". This element will not render.`,
);
}
return null;
})()}
>
{(el) => (
<ElementRenderer
element={el()}
spec={props.spec}
registry={props.registry}
loading={props.loading}
fallback={props.fallback}
/>
)}
</Show>
);
}}
</For>
);
return (
<Component
element={props.resolvedElement}
emit={props.emit}
on={props.on}
bindings={props.bindings}
loading={props.loading}
>
{children()}
</Component>
);
}}
</Show>
);
}
// ---------------------------------------------------------------------------
// RepeatChildren -- renders child elements once per item in a state array.
// Used when an element has a `repeat` field.
// ---------------------------------------------------------------------------
interface RepeatChildrenProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
function RepeatChildren(props: RepeatChildrenProps) {
const stateStore = useStateStore();
const repeat = () => props.element.repeat!;
const statePath = () => repeat().statePath;
const items = () =>
(getByPath(stateStore.state, statePath()) as unknown[] | undefined) ?? [];
return (
<For each={items()}>
{(itemValue, index) => {
const key = () => {
const r = repeat();
return r.key && typeof itemValue === "object" && itemValue !== null
? String((itemValue as Record<string, unknown>)[r.key] ?? index())
: String(index());
};
return (
<RepeatScopeProvider
item={itemValue}
index={index()}
basePath={`${statePath()}/${index()}`}
>
<For each={props.element.children ?? []}>
{(childKey) => {
const childElement = () => props.spec.elements[childKey];
return (
<Show
when={childElement()}
fallback={(() => {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`,
);
}
return null;
})()}
>
{(el) => (
<ElementRenderer
element={el()}
spec={props.spec}
registry={props.registry}
loading={props.loading}
fallback={props.fallback}
/>
)}
</Show>
);
}}
</For>
</RepeatScopeProvider>
);
}}
</For>
);
}
/**
* Main renderer component
*/
export function Renderer(props: RendererProps) {
const rootElement = createMemo(() => {
const spec = props.spec;
if (!spec || !spec.root) return undefined;
return spec.elements[spec.root];
});
return (
<Show when={rootElement()}>
{(el) => (
<ElementRenderer
element={el()}
spec={props.spec!}
registry={props.registry}
loading={props.loading}
fallback={props.fallback}
/>
)}
</Show>
);
}
/**
* Props for JSONUIProvider
*/
export interface JSONUIProviderProps {
/** Component registry */
registry: ComponentRegistry;
/**
* External store (controlled mode). When provided, `initialState` and
* `onStateChange` are ignored.
*/
store?: StateStore;
/** Initial state model (uncontrolled mode) */
initialState?: Record<string, unknown>;
/** Action handlers */
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
/** Navigation function */
navigate?: (path: string) => void;
/** Custom validation functions */
validationFunctions?: Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: JSX.Element;
}
/**
* Combined provider for all JSONUI contexts
*/
export function JSONUIProvider(props: JSONUIProviderProps) {
return (
<StateProvider
store={props.store}
initialState={props.initialState}
onStateChange={props.onStateChange}
>
<VisibilityProvider>
<ValidationProvider customFunctions={props.validationFunctions}>
<ActionProvider handlers={props.handlers} navigate={props.navigate}>
<FunctionsContext.Provider
value={props.functions ?? EMPTY_FUNCTIONS}
>
{props.children}
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
/**
* Renders the confirmation dialog when needed
*/
function ConfirmationDialogManager() {
const { pendingConfirmation, confirm, cancel } = useActions();
return (
<Show when={pendingConfirmation?.action.confirm}>
{(confirmConfig) => (
<ConfirmDialog
confirm={confirmConfig()}
onConfirm={confirm}
onCancel={cancel}
/>
)}
</Show>
);
}
// ============================================================================
// defineRegistry
// ============================================================================
/**
* Result returned by defineRegistry
*/
export interface DefineRegistryResult {
/** Component registry for `<Renderer registry={...} />` */
registry: ComponentRegistry;
/**
* Create ActionProvider-compatible handlers.
* Accepts getter functions so handlers always read the latest state/setState
* (e.g. from refs or closures).
*/
handlers: (
getSetState: () => SetState | undefined,
getState: () => StateModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
/**
* Execute an action by name imperatively
* (for use outside the Solid tree, e.g. initial state loading).
*/
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state?: StateModel,
) => Promise<void>;
}
/**
* Options for defineRegistry.
*
* When the catalog declares actions, the `actions` field is required.
* When the catalog has no actions (or `actions: {}`), the field is optional.
*/
type DefineRegistryOptions<C extends Catalog> = {
components?: Components<C>;
} & (CatalogHasActions<C> extends true
? { actions: Actions<C> }
: { actions?: Actions<C> });
/**
* Create a registry from a catalog with components and/or actions.
*
* When the catalog declares actions, the `actions` field is required.
*
* @example
* ```tsx
* // Components only (catalog has no actions)
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: (ctx) => (
* <div class="card">{ctx.props.title}{ctx.children}</div>
* ),
* },
* });
*
* // Both (catalog declares actions)
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
* });
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
// Build component registry
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = (renderProps: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
get props() {
return renderProps.element.props;
},
get children() {
return renderProps.children;
},
emit: renderProps.emit,
on: renderProps.on,
get bindings() {
return renderProps.bindings;
},
get loading() {
return renderProps.loading;
},
});
};
}
}
// Build action helpers
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetState: () => SetState | undefined,
getState: () => StateModel,
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
const result: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {};
for (const [name, actionFn] of actionMap) {
result[name] = async (params) => {
const setState = getSetState();
const state = getState();
if (setState) {
await actionFn(params, setState, state);
}
};
}
return result;
};
const executeAction = async (
actionName: string,
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel = {},
): Promise<void> => {
const entry = actionMap.find(([name]) => name === actionName);
if (entry) {
await entry[1](params, setState, state);
} else {
console.warn(`Unknown action: ${actionName}`);
}
};
return { registry, handlers, executeAction };
}
/** @internal */
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: JSX.Element;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}) => JSX.Element;
/** @internal */
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
// ============================================================================
// NEW API
// ============================================================================
/**
* Props for renderers created with createRenderer
*/
export interface CreateRendererProps {
/** The spec to render (AI-generated JSON) */
spec: Spec | null;
/**
* External store (controlled mode). When provided, `state` and
* `onStateChange` are ignored.
*/
store?: StateStore;
/** State context for dynamic values (uncontrolled mode) */
state?: Record<string, unknown>;
/** Action handler */
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
fallback?: ComponentRenderer;
}
/**
* Component map type - maps component names to Solid components
*/
export type ComponentMap<
TComponents extends Record<string, { props: unknown }>,
> = {
[K in keyof TComponents]: Component<
ComponentRenderProps<
TComponents[K]["props"] extends { _output: infer O }
? O
: Record<string, unknown>
>
>;
};
/**
* Create a renderer from a catalog
*
* @example
* ```typescript
* const DashboardRenderer = createRenderer(dashboardCatalog, {
* Card: (renderProps) => <div class="card">{renderProps.children}</div>,
* Metric: (renderProps) => <span>{renderProps.element.props.value}</span>,
* });
*
* // Usage
* <DashboardRenderer spec={aiGeneratedSpec} state={state} />
* ```
*/
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): Component<CreateRendererProps> {
// Convert component map to registry
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
// Return the renderer component
return function CatalogRenderer(props: CreateRendererProps) {
// Wrap onAction with a Proxy so any action name routes to the callback
const actionHandlers = () =>
props.onAction
? new Proxy(
{} as Record<
string,
(params: Record<string, unknown>) => void | Promise<void>
>,
{
get: (_target, prop: string) => {
return (params: Record<string, unknown>) =>
props.onAction!(prop, params);
},
has: () => true,
},
)
: undefined;
return (
<StateProvider
store={props.store}
initialState={props.state}
onStateChange={props.onStateChange}
>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers()}>
<FunctionsContext.Provider
value={props.functions ?? EMPTY_FUNCTIONS}
>
<Renderer
spec={props.spec}
registry={registry}
loading={props.loading}
fallback={props.fallback}
/>
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
};
}
+117
View File
@@ -0,0 +1,117 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/solid
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas, and optional actions
*/
export const schema = defineSchema(
(s) => ({
// What the AI-generated SPEC looks like
spec: s.object({
/** Root element key */
root: s.string(),
/** Flat map of elements by key */
elements: s.record(
s.object({
/** Component type from catalog */
type: s.ref("catalog.components"),
/** Component props */
props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */
children: s.array(s.string()),
/** Visibility condition */
visible: s.any(),
}),
),
}),
// What the CATALOG must provide
catalog: s.object({
/** Component definitions */
components: s.map({
/** Zod schema for component props */
props: s.zod(),
/** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
slots: s.array(s.string()),
/** Description for AI generation hints */
description: s.string(),
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
/** Zod schema for action params */
params: s.zod(),
/** Description for AI generation hints */
description: s.string(),
}),
}),
}),
{
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
],
defaultRules: [
// Element integrity
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
// Field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
// State and data
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
// Design quality
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
"Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
],
},
);
/**
* Type for the Solid schema
*/
export type SolidSchema = typeof schema;
/**
* Infer the spec type from a catalog
*/
export type SolidSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
// Backward compatibility aliases
/** @deprecated Use `schema` instead */
export const elementTreeSchema = schema;
/** @deprecated Use `SolidSchema` instead */
export type ElementTreeSchema = SolidSchema;
/** @deprecated Use `SolidSpec` instead */
export type ElementTreeSpec<T> = SolidSpec<T>;
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

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