@joycostudio/studio 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +271 -2
- package/dist/chunk-QKEAOM2U.js +13 -0
- package/dist/chunk-QKEAOM2U.js.map +1 -0
- package/dist/chunk-VYNEG5Z7.js +75 -0
- package/dist/chunk-VYNEG5Z7.js.map +1 -0
- package/dist/cli.js +687 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +63 -0
- package/dist/client.js +273 -0
- package/dist/client.js.map +1 -0
- package/dist/command-menu.css +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-BrHDtNkL.d.ts +140 -0
- package/dist/react.d.ts +184 -0
- package/dist/react.js +1527 -0
- package/dist/react.js.map +1 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +546 -0
- package/dist/runtime.js.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +98 -3
package/README.md
CHANGED
|
@@ -1,3 +1,272 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @joycostudio/studio
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Plug project-specific tools into a `/studio` route, the app's floating debug pane, and MCP agents, with one contract per tool.
|
|
4
|
+
|
|
5
|
+
For contributor instructions on stable releases, betas, and commit previews, see the
|
|
6
|
+
[release workflow guide](../../.changeset/README.md).
|
|
7
|
+
|
|
8
|
+
A tool's controls are an [Xyz](https://hub.joyco.studio/toolbox/xyz) debug schema (Tweakpane). That same schema:
|
|
9
|
+
|
|
10
|
+
- renders the inspector in `/studio`,
|
|
11
|
+
- mounts in the app's floating pane (`?debug` / <kbd>Option</kbd>+<kbd>D</kbd>) through Xyz's `DebugManager`,
|
|
12
|
+
- validates every write in the runtime (slider bounds, select options, types, and the shape of `initial`).
|
|
13
|
+
|
|
14
|
+
## 1. Define a tool
|
|
15
|
+
|
|
16
|
+
Tool files are loaded by the runtime in Node too, so keep them free of DOM/three imports and `@/` aliases.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// src/tools/grading.ts
|
|
20
|
+
import { defineTool } from '@joycostudio/studio'
|
|
21
|
+
import { property, select, slider } from '@joycostudio/xyz/debug'
|
|
22
|
+
import initial from './grading.json'
|
|
23
|
+
|
|
24
|
+
export const grading = defineTool({
|
|
25
|
+
id: 'color-grading',
|
|
26
|
+
file: 'src/tools/grading.json', // where saves go, repo-relative
|
|
27
|
+
initial, // the same file, imported: production ships the saved config
|
|
28
|
+
preview: '/', // route shown next to the inspector in /studio
|
|
29
|
+
controls: (config) => ({
|
|
30
|
+
exposure: slider(property(config, 'exposure'), { min: 0, max: 3 }),
|
|
31
|
+
tonemap: select(property(config, 'tonemap'), { ACES: 'aces', Filmic: 'filmic' }),
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Register tools with the runtime
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// studio.config.ts (its directory is the project root)
|
|
40
|
+
import { defineConfig } from '@joycostudio/studio'
|
|
41
|
+
import { grading } from './src/tools/grading'
|
|
42
|
+
|
|
43
|
+
export default defineConfig({ tools: [grading] })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{ "scripts": { "dev": "studio dev -- next dev" } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`studio dev` starts the WebSocket bridge on `ws://127.0.0.1:5179` and the HTTP MCP endpoint on
|
|
51
|
+
`http://127.0.0.1:5179/mcp` (`--port` changes both). It stops when the wrapped command exits.
|
|
52
|
+
|
|
53
|
+
## 3. Use it in the app
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// src/studio.ts
|
|
57
|
+
import { createStudio } from '@joycostudio/studio/client'
|
|
58
|
+
|
|
59
|
+
export const studio = createStudio({
|
|
60
|
+
tools: [grading],
|
|
61
|
+
runtime: process.env.NODE_ENV === 'development' && 'ws://127.0.0.1:5179',
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const tool = studio.tool(grading)
|
|
67
|
+
apply(tool.config) // a live object: read it in your render loop…
|
|
68
|
+
tool.subscribe(apply) // …or react to every change, local or remote
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Floating debug pane, with the same controls (plus save/discard when the runtime is up):
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { everyFrame } from '@joycostudio/studio/client'
|
|
75
|
+
import { DebugManager } from '@joycostudio/xyz/debug'
|
|
76
|
+
import { Pane } from 'tweakpane'
|
|
77
|
+
|
|
78
|
+
const debug = new DebugManager({ createPane: () => new Pane(), binding: { registerFrame: everyFrame } })
|
|
79
|
+
for (const tool of studio.tools) debug.register(tool)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `/studio` route:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// app/studio/page.tsx
|
|
86
|
+
'use client'
|
|
87
|
+
import { Studio } from '@joycostudio/studio/react'
|
|
88
|
+
import { studio } from '@/studio'
|
|
89
|
+
|
|
90
|
+
export default function StudioPage() {
|
|
91
|
+
return <Studio studio={studio} />
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use the pin button beside a sidebar tool to keep it at the top. Pinned tools also appear first in the
|
|
96
|
+
**Command/Ctrl+K** menu. Pins are saved in this browser for the current origin and shared across tabs;
|
|
97
|
+
unpinning restores registration order. Pinning a tool keeps the current preview selected.
|
|
98
|
+
|
|
99
|
+
In Studio, **Command+B** (Mac) or **Ctrl+B** slides both side panels out of view; press it again to bring them back.
|
|
100
|
+
This also works from the same-origin preview iframe. The sidebar's collapse/expand button controls its width separately,
|
|
101
|
+
and that choice is preserved when hiding and showing the panels. Move the pointer near either screen edge to reveal a small
|
|
102
|
+
tab; click it to bring both panels back. The tabs also appear on keyboard focus and stay visible on touch devices.
|
|
103
|
+
With reduced motion enabled, panels toggle instantly.
|
|
104
|
+
|
|
105
|
+
Previews use the shared `PreviewCanvas`: Reload and the tool title sit on the left, with Expand and
|
|
106
|
+
header pinning on the right. Expand/Contain keeps the iframe and its scene mounted. Headers start
|
|
107
|
+
pinned; enabling auto-hide overlays the header and hides it after three seconds away. Hover, keyboard
|
|
108
|
+
focus, and open menus keep it visible, and touch devices always show the controls. Contained previews
|
|
109
|
+
fit between the side panels, with the inspector stacked below on narrow screens.
|
|
110
|
+
|
|
111
|
+
### Shared canvas and panel components
|
|
112
|
+
|
|
113
|
+
Import these from `@joycostudio/studio/react` with `@joycostudio/studio/styles.css`:
|
|
114
|
+
|
|
115
|
+
| Component | Use |
|
|
116
|
+
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
117
|
+
| `StudioSurface`, `StudioToolbar` | Shared inset card and compact action row, including button states. |
|
|
118
|
+
| `CanvasToolbar` | Pinned/auto-hide header; `label`, optional `expanded`/`onToggle`, and child controls. |
|
|
119
|
+
| `PreviewCanvas` | Iframe panel with reload, expansion, and a `toolbar` slot replacing the title. |
|
|
120
|
+
| `StudioSelect`, `StudioSelectItem` | Labeled Radix select with Studio field and portaled menu styling. |
|
|
121
|
+
| `StudioInput` | Standard input with logical padding (`ps-*`/`pe-*`) for icon-bearing fields. |
|
|
122
|
+
| `StudioPanel`, `StudioPanelHeader`, `StudioPanelContent`, `StudioPanelFooter` | Floating panel surface, inset sections, and scrolling body used by Studio's inspector. |
|
|
123
|
+
|
|
124
|
+
`PreviewCanvas` handles expansion itself, or accepts controlled `expanded`/`onExpandedChange` props.
|
|
125
|
+
Native iframe props (including `className`, `onLoad`, and `title`) and its ref reach the actual iframe;
|
|
126
|
+
`onReload` runs before an explicit reload. `StudioSurface` and `StudioPanel` support `asChild` to reuse
|
|
127
|
+
their appearance on semantic elements without an extra wrapper. Panel layout is caller-owned.
|
|
128
|
+
|
|
129
|
+
The package owns the styles and interactions extracted from the BZRP Studio workspace, including
|
|
130
|
+
6px toolbar padding, 4px gaps, 8px padding on title text, and continuous sidebar header/footer surfaces.
|
|
131
|
+
Consumers can replace local copies of the surface, toolbar, preview, select, and input components with
|
|
132
|
+
these imports. Module graphs, tracing, and scene-specific controls remain host-owned.
|
|
133
|
+
|
|
134
|
+
### Host preview and source-link extensions
|
|
135
|
+
|
|
136
|
+
`Studio` accepts `previewComponent` and `getConfigFileHref`, so integrations no longer need to patch
|
|
137
|
+
the built package. Keep a custom preview component at module scope and forward its iframe ref and
|
|
138
|
+
props; Studio uses them for keyboard shortcuts, comparison, and tool-switch lifecycle:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
|
|
142
|
+
import { PreviewCanvas, Studio } from '@joycostudio/studio/react'
|
|
143
|
+
|
|
144
|
+
const ProjectPreview = forwardRef<HTMLIFrameElement, ComponentPropsWithoutRef<'iframe'>>(
|
|
145
|
+
(props, ref) => <PreviewCanvas {...props} ref={ref} />,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
<Studio
|
|
149
|
+
studio={studio}
|
|
150
|
+
previewComponent={ProjectPreview}
|
|
151
|
+
getConfigFileHref={(file) => new URL(file, sourceBaseUrl).href}
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The source base URL belongs to the host: it can point to a source browser or a development-only
|
|
156
|
+
`vscode://file/` URL for the current checkout. Without the callback, config paths remain plain text.
|
|
157
|
+
See the playground's `/studio/components` route for controlled expansion, custom toolbar fields,
|
|
158
|
+
and source links in use.
|
|
159
|
+
|
|
160
|
+
For a tool that already renders its own panels and headers, set `previewLayout: 'custom'` in its
|
|
161
|
+
`defineTool` definition. Studio uses a bare iframe fitted to the available panel area, avoiding
|
|
162
|
+
route-specific checks such as `/studio/modules`. A supplied `previewComponent` takes precedence.
|
|
163
|
+
Use `previewComponent="iframe"` to retain the original full-window preview behavior for all tools.
|
|
164
|
+
|
|
165
|
+
When migrating the BZRP integration, remove its custom-preview/config-link pnpm patch, use these
|
|
166
|
+
exports, and remove the generic shell/toolbar CSS that Studio now supplies. Keep application-specific
|
|
167
|
+
module runtime expansion, source indexing, and session-local inspector adapters in the host.
|
|
168
|
+
|
|
169
|
+
### Jump to tools from anywhere
|
|
170
|
+
|
|
171
|
+
Mount `StudioCommandMenu` once in the host app's root layout so it stays available on every route, including `/studio`:
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
// src/command-menu.tsx
|
|
175
|
+
'use client'
|
|
176
|
+
import '@joycostudio/studio/command-menu.css'
|
|
177
|
+
import { StudioCommandMenu } from '@joycostudio/studio/react'
|
|
178
|
+
import { studio } from './studio'
|
|
179
|
+
|
|
180
|
+
export function CommandMenu() {
|
|
181
|
+
return <StudioCommandMenu studio={studio} />
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Render `<CommandMenu />` alongside the layout's `{children}`. The host owns mounting it, just like the floating debug pane.
|
|
186
|
+
Its stylesheet is scoped to the menu and can be imported globally without applying Studio's page styles to the host app.
|
|
187
|
+
|
|
188
|
+
Press **Command+K** (Mac) or **Ctrl+K** to open or close it, type a tool name or ID to search, use **↑/↓** and **Enter** to
|
|
189
|
+
select, or **Escape** to dismiss. Clicking a result also opens it. Results link to `/studio#tool-id`; switching tools from
|
|
190
|
+
inside Studio changes the hash without reloading the page. The shortcut also works while focus is in Studio's same-origin
|
|
191
|
+
preview iframe, opening the menu above Studio rather than inside the preview.
|
|
192
|
+
|
|
193
|
+
Pass `studioPath="/custom/studio"` for another route. For client-side navigation with a host router, pass its navigation
|
|
194
|
+
function, for example `<StudioCommandMenu studio={studio} navigate={router.push} />` with Next.js `useRouter()`.
|
|
195
|
+
Without a router callback, navigation from another route uses the browser. The menu works without a runtime connection.
|
|
196
|
+
|
|
197
|
+
To run its browser tests, start the playground and run
|
|
198
|
+
`STUDIO_TEST_APP_URL=http://localhost:3000 pnpm --filter @joycostudio/studio exec vitest run test/command-menu.test.ts`.
|
|
199
|
+
Install Chromium first with `pnpm exec studio install-browser` if needed.
|
|
200
|
+
|
|
201
|
+
## 4. Connect an MCP agent
|
|
202
|
+
|
|
203
|
+
Start the app with `studio dev -- <your dev command>` as above. Add a **Streamable HTTP** MCP server to your agent with
|
|
204
|
+
this URL:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
http://127.0.0.1:5179/mcp
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Use the URL printed by the CLI when running on a different port. Each project's runtime needs its own port when several
|
|
211
|
+
projects run at once. The endpoint is local to the machine running Studio; a remote/cloud agent cannot reach it through
|
|
212
|
+
its own `localhost`.
|
|
213
|
+
|
|
214
|
+
The MCP endpoint shares the running app's drafts. It supports both the 2025 MCP initialization flow and the 2026 MCP
|
|
215
|
+
discovery flow, with no separate runtime or MCP process to launch.
|
|
216
|
+
|
|
217
|
+
| MCP tool | Arguments | Result |
|
|
218
|
+
| ------------------------ | ---------------------------------------------- | ---------------------------------------------------------------- |
|
|
219
|
+
| `studio_list_tools` | none | Registered IDs, titles, file paths, and preview routes |
|
|
220
|
+
| `studio_get_tool` | `tool` | Complete draft, revision, saved value, dirty and conflict status |
|
|
221
|
+
| `studio_update_tool` | `tool`, `revision`, `value` | Validated draft; all connected previews update immediately |
|
|
222
|
+
| `studio_save_tool` | `tool`, `revision`, optional `force` | Draft persisted to its registered JSON file |
|
|
223
|
+
| `studio_discard_tool` | `tool`, `revision` | Draft reset to the runtime's latest saved value |
|
|
224
|
+
| `studio_capture_preview` | `tool`, optional `revision`, `width`, `height` | PNG image and draft revision from a fresh preview page |
|
|
225
|
+
|
|
226
|
+
Read before editing, copy `data.draft.value`, change the intended fields, then pass the **complete config** and
|
|
227
|
+
`data.draft.revision` to `studio_update_tool`. Saving is separate. Use the new revision returned by the update to save or
|
|
228
|
+
discard. If the playground or another agent edits in between, a `stale_revision` error tells you to read again and reconcile
|
|
229
|
+
the changes. `force` bypasses a disk conflict, never a stale revision.
|
|
230
|
+
|
|
231
|
+
Every tool returns the runtime's `{ ok: true, data }` or `{ ok: false, error, message? }` as structured content and JSON text.
|
|
232
|
+
Runtime failures also set MCP's `isError`, so invalid configs, unknown tools, stale revisions, and disk conflicts remain
|
|
233
|
+
visible to the agent. Expected failures leave the draft intact.
|
|
234
|
+
|
|
235
|
+
### Preview images
|
|
236
|
+
|
|
237
|
+
Set the running app's URL once in `studio.config.ts` (or override it with `studio dev --app-url <url>`):
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
export default defineConfig({ tools: [grading], appUrl: 'http://localhost:3000' })
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Install Chromium once with `pnpm exec studio install-browser`. Then call `studio_capture_preview` with a tool ID.
|
|
244
|
+
The existing `preview` route must use the Studio client connected to this runtime. No capture code or readiness hooks
|
|
245
|
+
are needed in tool definitions, HTML components, or canvas renderers.
|
|
246
|
+
|
|
247
|
+
Capture starts a shared headless Chromium browser on first use and opens an isolated page for each request. It reads the
|
|
248
|
+
live unsaved draft, waits for the client to receive that revision, then waits for fonts, current HTML images, and two
|
|
249
|
+
animation frames. CSS animations are disabled for the screenshot. This is automatic settling, not a guarantee that
|
|
250
|
+
arbitrary async work, delayed UI, or WebGL assets have finished. A draft change during capture returns `stale_revision`
|
|
251
|
+
instead of an image. Simultaneous captures return `capture_busy`; retry after the active capture finishes.
|
|
252
|
+
|
|
253
|
+
The result includes a PNG as MCP image content and metadata with `tool`, `revision`, `url`, `width`, `height`, and
|
|
254
|
+
`settling: 'automatic'`. The viewport defaults to 1200×800 at 1× scale; width accepts 320–2560 and height accepts 240–1600.
|
|
255
|
+
The page starts fresh: it does not inherit your open tab's login, scroll, or interaction state. Preview routes and
|
|
256
|
+
top-level navigation must stay on the configured app origin. Pages that never connect or settle time out.
|
|
257
|
+
|
|
258
|
+
For the browser integration tests, run `pnpm exec studio install-browser` and
|
|
259
|
+
`pnpm --filter @joycostudio/studio test:capture`. The regular test suite runs setup validation without requiring Chromium.
|
|
260
|
+
|
|
261
|
+
The server binds to `127.0.0.1` by default and the MCP endpoint checks localhost Host headers and the same browser Origin
|
|
262
|
+
allowlist as the WebSocket bridge. Native agent clients do not need an Origin header. This is a local development endpoint
|
|
263
|
+
without authentication; do not expose it through a public proxy.
|
|
264
|
+
|
|
265
|
+
## How it fits together
|
|
266
|
+
|
|
267
|
+
- **Runtime** (`studio dev`) is the only writer. It validates every update, keeps a draft revision per tool, writes atomically,
|
|
268
|
+
refuses paths outside the project (symlinks included), only accepts localhost browser origins, and watches files for
|
|
269
|
+
external edits (an external edit over unsaved work becomes a `conflict` that needs discard or force save).
|
|
270
|
+
- **Bridge**: WebSocket (`tools.list`, `tool.get`, `tool.update`, `tool.save`, `tool.discard` + `state` broadcasts) and HTTP MCP
|
|
271
|
+
on the same port. `/studio`, every app tab, and MCP all go through these same runtime operations.
|
|
272
|
+
- **Without a runtime** (production) tools stay local: the floating pane still tweaks, nothing persists.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/core/result.ts
|
|
2
|
+
var success = (data) => ({ ok: true, data });
|
|
3
|
+
var error = (code, message) => ({
|
|
4
|
+
ok: false,
|
|
5
|
+
error: code,
|
|
6
|
+
...message === void 0 ? {} : { message }
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
success,
|
|
11
|
+
error
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=chunk-QKEAOM2U.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/result.ts"],"sourcesContent":["export type Success<T> = { ok: true; data: T }\n\nexport type Failure = {\n ok: false\n /** snake_case wire code, e.g. `invalid_config`. */\n error: string\n message?: string\n}\n\nexport type Result<T> = Success<T> | Failure\n\nexport const success = <T>(data: T): Success<T> => ({ ok: true, data })\n\nexport const error = (code: string, message?: string): Failure => ({\n ok: false,\n error: code,\n ...(message === undefined ? {} : { message }),\n})\n"],"mappings":";AAWO,IAAM,UAAU,CAAI,UAAyB,EAAE,IAAI,MAAM,KAAK;AAE9D,IAAM,QAAQ,CAAC,MAAc,aAA+B;AAAA,EACjE,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,GAAI,YAAY,SAAY,CAAC,IAAI,EAAE,QAAQ;AAC7C;","names":[]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
error,
|
|
3
|
+
success
|
|
4
|
+
} from "./chunk-QKEAOM2U.js";
|
|
5
|
+
|
|
6
|
+
// src/core/validate.ts
|
|
7
|
+
function validateConfig(tool, candidate) {
|
|
8
|
+
const mismatch = shapeMismatch(tool.initial, candidate, "config");
|
|
9
|
+
if (mismatch) return error("invalid_config", mismatch);
|
|
10
|
+
const config = structuredClone(candidate);
|
|
11
|
+
const invalid = invalidControl(tool.controls(config));
|
|
12
|
+
return invalid ? error("invalid_config", invalid) : success(config);
|
|
13
|
+
}
|
|
14
|
+
function kind(value) {
|
|
15
|
+
if (value === null) return "null";
|
|
16
|
+
if (Array.isArray(value)) return "array";
|
|
17
|
+
return typeof value;
|
|
18
|
+
}
|
|
19
|
+
function shapeMismatch(reference, value, path) {
|
|
20
|
+
const expected = kind(reference);
|
|
21
|
+
if (kind(value) !== expected) return `${path}: expected ${expected}, got ${kind(value)}`;
|
|
22
|
+
if (expected === "number" && !Number.isFinite(value)) return `${path}: expected a finite number`;
|
|
23
|
+
if (expected === "array") {
|
|
24
|
+
const [first] = reference;
|
|
25
|
+
if (first === void 0) return null;
|
|
26
|
+
for (const [index, item] of value.entries()) {
|
|
27
|
+
const nested = shapeMismatch(first, item, `${path}[${index}]`);
|
|
28
|
+
if (nested) return nested;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (expected === "object") {
|
|
32
|
+
const ref = reference;
|
|
33
|
+
const obj = value;
|
|
34
|
+
for (const key of Object.keys(obj)) if (!(key in ref)) return `${path}.${key}: unknown key`;
|
|
35
|
+
for (const key of Object.keys(ref)) {
|
|
36
|
+
if (!(key in obj)) return `${path}.${key}: missing`;
|
|
37
|
+
const nested = shapeMismatch(ref[key], obj[key], `${path}.${key}`);
|
|
38
|
+
if (nested) return nested;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
var isRgb = (value) => typeof value === "object" && value !== null && ["r", "g", "b"].every((channel) => Number.isFinite(value[channel]));
|
|
44
|
+
function invalidControl(schema, path = "") {
|
|
45
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
46
|
+
if (field.kind === "button") continue;
|
|
47
|
+
const name = `${path}${field.label ?? key}`;
|
|
48
|
+
if (field.kind === "folder") {
|
|
49
|
+
const nested = invalidControl(field.definition.schema(), `${name}/`);
|
|
50
|
+
if (nested) return nested;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const value = field.read();
|
|
54
|
+
if (field.kind === "slider") {
|
|
55
|
+
if (typeof value !== "number" || value < field.min || value > field.max)
|
|
56
|
+
return `${name}: expected a number in [${field.min}, ${field.max}], got ${JSON.stringify(value)}`;
|
|
57
|
+
} else if (field.kind === "bool") {
|
|
58
|
+
if (typeof value !== "boolean") return `${name}: expected a boolean`;
|
|
59
|
+
} else if (field.kind === "select") {
|
|
60
|
+
const options = Object.values(field.options);
|
|
61
|
+
if (!options.includes(value))
|
|
62
|
+
return `${name}: expected one of ${JSON.stringify(options)}, got ${JSON.stringify(value)}`;
|
|
63
|
+
} else if (field.kind === "color-string") {
|
|
64
|
+
if (typeof value !== "string") return `${name}: expected a color string`;
|
|
65
|
+
} else if (field.kind === "color") {
|
|
66
|
+
if (!isRgb(value)) return `${name}: expected an { r, g, b } color`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
validateConfig
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=chunk-VYNEG5Z7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/validate.ts"],"sourcesContent":["import type { DebugSchema } from '@joycostudio/xyz/debug'\nimport { error, success, type Result } from './result'\nimport type { ToolDefinition } from './tool'\n\n/**\n * Accept `candidate` as a config for `tool` only if it has the exact shape of\n * `tool.initial` and every control in `tool.controls` accepts its value.\n * Returns a detached copy, never the input.\n */\nexport function validateConfig<TConfig extends object>(\n tool: ToolDefinition<TConfig>,\n candidate: unknown,\n): Result<TConfig> {\n const mismatch = shapeMismatch(tool.initial, candidate, 'config')\n if (mismatch) return error('invalid_config', mismatch)\n const config = structuredClone(candidate) as TConfig\n const invalid = invalidControl(tool.controls(config))\n return invalid ? error('invalid_config', invalid) : success(config)\n}\n\nfunction kind(value: unknown) {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'array'\n return typeof value\n}\n\nfunction shapeMismatch(reference: unknown, value: unknown, path: string): string | null {\n const expected = kind(reference)\n if (kind(value) !== expected) return `${path}: expected ${expected}, got ${kind(value)}`\n if (expected === 'number' && !Number.isFinite(value)) return `${path}: expected a finite number`\n if (expected === 'array') {\n const [first] = reference as unknown[]\n if (first === undefined) return null\n for (const [index, item] of (value as unknown[]).entries()) {\n const nested = shapeMismatch(first, item, `${path}[${index}]`)\n if (nested) return nested\n }\n }\n if (expected === 'object') {\n const ref = reference as Record<string, unknown>\n const obj = value as Record<string, unknown>\n for (const key of Object.keys(obj)) if (!(key in ref)) return `${path}.${key}: unknown key`\n for (const key of Object.keys(ref)) {\n if (!(key in obj)) return `${path}.${key}: missing`\n const nested = shapeMismatch(ref[key], obj[key], `${path}.${key}`)\n if (nested) return nested\n }\n }\n return null\n}\n\nconst isRgb = (value: unknown) =>\n typeof value === 'object' &&\n value !== null &&\n ['r', 'g', 'b'].every((channel) => Number.isFinite((value as Record<string, unknown>)[channel]))\n\nfunction invalidControl(schema: DebugSchema, path = ''): string | null {\n for (const [key, field] of Object.entries(schema)) {\n if (field.kind === 'button') continue\n const name = `${path}${field.label ?? key}`\n if (field.kind === 'folder') {\n const nested = invalidControl(field.definition.schema(), `${name}/`)\n if (nested) return nested\n continue\n }\n const value: unknown = field.read()\n if (field.kind === 'slider') {\n if (typeof value !== 'number' || value < field.min || value > field.max)\n return `${name}: expected a number in [${field.min}, ${field.max}], got ${JSON.stringify(value)}`\n } else if (field.kind === 'bool') {\n if (typeof value !== 'boolean') return `${name}: expected a boolean`\n } else if (field.kind === 'select') {\n const options = Object.values(field.options)\n if (!options.includes(value as string | number))\n return `${name}: expected one of ${JSON.stringify(options)}, got ${JSON.stringify(value)}`\n } else if (field.kind === 'color-string') {\n if (typeof value !== 'string') return `${name}: expected a color string`\n } else if (field.kind === 'color') {\n if (!isRgb(value)) return `${name}: expected an { r, g, b } color`\n }\n }\n return null\n}\n"],"mappings":"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","names":[]}
|