mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124be9708c | ||
|
|
f6d1c5134b | ||
|
|
316439fcd7 | ||
|
|
c180f529a0 | ||
|
|
c3c0a36f20 | ||
|
|
9bb82604f0 |
@@ -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": [],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
"@json-render/core": minor
|
||||
"@json-render/solid": minor
|
||||
"@json-render/react-three-fiber": minor
|
||||
---
|
||||
|
||||
Add SolidJS and React Three Fiber renderers, plus strict JSON Schema mode for LLM structured outputs.
|
||||
|
||||
### New:
|
||||
|
||||
- **`@json-render/solid`** -- SolidJS renderer. JSON becomes Solid components with reactive rendering, schema export, and full catalog support.
|
||||
- **`@json-render/react-three-fiber`** -- React Three Fiber renderer. JSON becomes 3D scenes with 19 built-in components for meshes, lights, models, environments, text, cameras, and controls.
|
||||
|
||||
### Improved:
|
||||
|
||||
- **`@json-render/core`** -- `jsonSchema({ strict: true })` produces a JSON Schema subset compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic). Ensures `additionalProperties: false` on every object and all properties listed in `required`.
|
||||
@@ -21,6 +21,10 @@ 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
|
||||
# or for 3D scenes
|
||||
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
@@ -30,7 +34,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 +95,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 +115,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 +163,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 +203,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 +253,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
},
|
||||
});
|
||||
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
<Renderer spec={spec} registry={registry} />;
|
||||
```
|
||||
|
||||
### React Native (Mobile)
|
||||
@@ -250,23 +274,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 +317,7 @@ const spec = {
|
||||
fps={spec.composition.fps}
|
||||
compositionWidth={spec.composition.width}
|
||||
compositionHeight={spec.composition.height}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
### React PDF (Documents)
|
||||
@@ -287,7 +328,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 +346,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 +378,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 +391,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 +437,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 +527,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 +555,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 +572,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 +605,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 +615,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<string, unknown></code></td>
|
||||
<td>Initial state (uncontrolled mode)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>handlers</code></td>
|
||||
<td><code>Record<string, Function></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">;
|
||||
```
|
||||
@@ -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<string, unknown></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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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's what you'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'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'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>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>11k</span>
|
||||
<span>12k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { App } from "./App";
|
||||
import "./app.css";
|
||||
|
||||
render(() => <App />, document.getElementById("app")!);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {},
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {};
|
||||
|
||||
@@ -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/react-three-fiber](https://json-render.dev/docs/api/react-three-fiber)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user