@joycostudio/studio 0.1.3 → 0.1.5

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 CHANGED
@@ -11,106 +11,231 @@ A tool's controls are an [Xyz](https://hub.joyco.studio/toolbox/xyz) debug schem
11
11
  - mounts in the app's floating pane (`?debug` / <kbd>Option</kbd>+<kbd>D</kbd>) through Xyz's `DebugManager`,
12
12
  - validates every write in the runtime (slider bounds, select options, types, and the shape of `initial`).
13
13
 
14
- ## 1. Define a tool
14
+ ## Quick start: Next.js App Router
15
15
 
16
- Tool files are loaded by the runtime in Node too, so keep them free of DOM/three imports and `@/` aliases.
16
+ In an existing TypeScript Next.js app:
17
+
18
+ ```sh
19
+ pnpm dlx @joycostudio/studio init
20
+ pnpm dev
21
+ ```
22
+
23
+ Open `/studio` and select Example. Move the Scale slider and Save to write the adjacent JSON.
24
+ The initializer supports `app/` and `src/app/`, installs dependencies, wraps your dev command,
25
+ and adds generation to your typecheck script. It wraps the existing Next config with `withStudio`
26
+ so production builds also generate their registry on a clean checkout. The home page stays yours.
27
+ Use `studio init <directory>` to target another app, or `--no-install` to prepare files without installing.
28
+ Reruns skip identical files; conflicting or edited files stop initialization before writes.
29
+ If generation fails, initialization restores existing files and removes newly created setup files.
30
+ Route checks include route groups and handlers. If Studio is already declared, install a version
31
+ with the new exports before running init; an older or missing installation is rejected before writes.
32
+ Existing Studio integrations and custom shell dev scripts use the manual migration below.
33
+
34
+ ## One folder, one tool
35
+
36
+ Write these three files anywhere under your active app directory:
37
+
38
+ ```text
39
+ src/app/experiments/lighting/
40
+ page.tsx # Your normal Next.js preview
41
+ studio.tool.ts # Controls and explicit stable ID
42
+ config.json # Saved values
43
+ ```
44
+
45
+ `config.json`:
46
+
47
+ ```json
48
+ { "intensity": 1 }
49
+ ```
17
50
 
18
51
  ```ts
19
- // src/tools/grading.ts
52
+ // studio.tool.ts
20
53
  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
54
+ import { property, slider } from '@joycostudio/studio/controls'
55
+
56
+ export default defineTool({
57
+ id: 'lighting',
29
58
  controls: (config) => ({
30
- exposure: slider(property(config, 'exposure'), { min: 0, max: 3 }),
31
- tonemap: select(property(config, 'tonemap'), { ACES: 'aces', Filmic: 'filmic' }),
59
+ intensity: slider(property(config, 'intensity'), { min: 0, max: 5 }),
32
60
  }),
33
61
  })
34
62
  ```
35
63
 
36
- ### Preview-only tools
64
+ Studio discovers `studio.tool.ts` and infers the JSON source, save destination, preview URL,
65
+ and title. `id` is explicit so moving the route does not change its identity. Controls receive
66
+ the JSON's inferred TypeScript shape through a generated module augmentation; imports always
67
+ come from `@joycostudio/studio`, never a per-tool generated helper.
37
68
 
38
- A tool with nothing to tune leaves out `file`, `initial`, and `controls`. It still gets a sidebar entry, a pin, and a
39
- ⌘/Ctrl+K entry, but Studio shows only its preview, with no inspector. Register it with the rest of the tools.
69
+ The preview consumes the live config:
70
+
71
+ ```tsx
72
+ // src/app/experiments/lighting/page.tsx
73
+ 'use client'
74
+ import { useToolConfig } from '@joycostudio/studio/react'
75
+ import { studio } from '../../../studio'
76
+ import lighting from './studio.tool'
77
+
78
+ export default function LightingPage() {
79
+ const config = useToolConfig(studio.tool(lighting))
80
+ return <div style={{ filter: `brightness(${config.intensity})` }}>Your scene</div>
81
+ }
82
+ ```
83
+
84
+ For a render loop, read `studio.tool(lighting).config` directly, or subscribe to changes with
85
+ `studio.tool(lighting).subscribe(apply)`. Tool definitions also load in Node: keep DOM, React
86
+ components, and renderer imports in your page or scene, not in `studio.tool.ts`.
87
+
88
+ ### Overrides and discovery scope
40
89
 
41
90
  ```ts
42
- export const gallery = defineTool({ id: 'gallery', title: 'Gallery', preview: '/gallery' })
91
+ export default defineTool({
92
+ id: 'lighting',
93
+ title: 'Scene Lighting',
94
+ config: '@/settings/lighting.json',
95
+ preview: '/scene',
96
+ controls: (config) => ({/* ... */}),
97
+ })
43
98
  ```
44
99
 
45
- `studio.definitions` lists every tool in order. `studio.tools` holds handles for tools with a config, so preview-only
46
- tools never reach the debug pane. Over MCP they are listed without a `file` and can't be read, edited, or captured.
100
+ `config` is a single JSON path for both initial values and saves. It defaults to `./config.json`;
101
+ relative paths resolve from the tool file, and aliases resolve through the app's `tsconfig.json`,
102
+ including inherited paths. JSON must exist and remain inside the project. Two tools cannot write
103
+ the same file. For deterministic generation, default-export `defineTool({ ... })` directly:
104
+ `id`, `config`, and explicit `preview` must be string literals, with no spread/computed option keys.
105
+ Control callbacks can contain normal application logic.
47
106
 
48
- ## 2. Register tools with the runtime
107
+ An optional, **user-owned** `studio.config.ts` controls discovery:
49
108
 
50
109
  ```ts
51
- // studio.config.ts (its directory is the project root)
52
110
  import { defineConfig } from '@joycostudio/studio'
53
- import { grading } from './src/tools/grading'
54
111
 
55
- export default defineConfig({ tools: [grading] })
112
+ export default defineConfig({
113
+ toolsDir: 'src/app/experiments',
114
+ // appUrl: 'http://localhost:3000', // Optional MCP capture URL
115
+ })
56
116
  ```
57
117
 
58
- ```json
59
- { "scripts": { "dev": "studio dev -- next dev" } }
60
- ```
118
+ `toolsDir` is relative to the project root and limits scanning and watching. By default Studio uses
119
+ `app/`, or `src/app/` when there is no root `app/`. Narrowing it does not change preview URLs.
120
+ Hidden directories, node_modules, and symlinked directories are excluded. Route groups such as
121
+ `(experiments)` are stripped from inferred URLs. Dynamic, parallel, intercepted, private, or
122
+ outside-app routes need an explicit concrete `preview`; a missing adjacent page also needs one.
123
+ A preview-only tool can omit `controls` and JSON entirely: `defineTool({ id: 'gallery' })`.
61
124
 
62
- `studio dev` starts the WebSocket bridge on `ws://127.0.0.1:5179` and the HTTP MCP endpoint on
63
- `http://127.0.0.1:5179/mcp` (`--port` or `port` in the config changes both). It stops when the wrapped command exits.
125
+ ### Generated files and startup
64
126
 
65
- Several projects can run at once. When another project's Studio already holds the port, `studio dev` moves to the next
66
- free one and passes it to the wrapped command as `STUDIO_PORT`. Read that variable instead of hardcoding the port:
127
+ Studio owns only `.studio/registry.generated.ts` and `.studio/types.generated.ts`; ignore `.studio/`
128
+ in Git. The registry references the type map, so importing the registry includes its augmentation
129
+ in the application's TypeScript program. Scaffolded app files remain user-owned after init.
67
130
 
68
131
  ```ts
69
- // next.config.ts
70
- export default { env: { NEXT_PUBLIC_STUDIO_PORT: process.env.STUDIO_PORT ?? '5179' } }
71
- ```
132
+ // src/studio.ts (scaffolded once)
133
+ import { createStudio } from '@joycostudio/studio/next'
134
+ import { tools } from '../.studio/registry.generated'
72
135
 
73
- ## 3. Use it in the app
136
+ export const studio = createStudio({ tools })
137
+ ```
74
138
 
75
139
  ```ts
76
- // src/studio.ts
77
- import { createStudio } from '@joycostudio/studio/client'
140
+ // next.config.ts
141
+ import { withStudio } from '@joycostudio/studio/next/config'
78
142
 
79
- export const studio = createStudio({
80
- tools: [grading],
81
- runtime: process.env.NODE_ENV === 'development' && `ws://127.0.0.1:${process.env.NEXT_PUBLIC_STUDIO_PORT}`,
82
- })
143
+ export default withStudio({/* your existing Next options */})
83
144
  ```
84
145
 
85
- ```ts
86
- const tool = studio.tool(grading)
87
- apply(tool.config) // a live object: read it in your render loop…
88
- tool.subscribe(apply) // …or react to every change, local or remote
146
+ `withStudio` accepts an object or an async/sync Next configuration function and generates before
147
+ `next dev` or `next build`. `studio dev -- next dev` generates first and owns discovery watching
148
+ when the runtime is running. Plain `next dev` also watches, but uses browser-only config syncing.
149
+ For standalone typechecking, run `studio generate && tsc --noEmit` (init configures this script).
150
+ If your TypeScript project excludes the client/registry, explicitly include `.studio/types.generated.ts`.
151
+ Restart the dev server after changing runtime port, origins, or capture URL settings.
152
+
153
+ During `studio dev`, value-only config changes travel through the runtime transport without
154
+ Next Fast Refresh or a preview remount, including saves and external JSON edits. The generated
155
+ registry embeds stable bootstrap values instead of importing the writable JSON at runtime;
156
+ type-only imports still infer controls from the source JSON. New pages receive current values
157
+ when they connect to the runtime. Changes to JSON structure/types regenerate the bootstrap values.
158
+ A new runtime session, plain `next dev`, `studio generate`, and production builds read fresh values.
159
+ Read live values through `studio.tool(...)` or `useToolConfig`; directly importing the JSON into
160
+ your page or tool helpers gives Next a dependency that will still trigger HMR on saves.
161
+
162
+ Adding/removing/renaming tools refreshes the registry. Normal page-content edits stay with Next's
163
+ HMR. Tool definitions and their local imported helpers are tracked, as are referenced JSON files
164
+ outside `toolsDir` and TypeScript alias settings. Output is written only when changed.
165
+ Missing or malformed JSON configs remain watched after a rejected update, so completing them
166
+ retries discovery automatically, including aliases outside `toolsDir`.
167
+ The runtime preserves valid drafts during refresh; removing or moving a dirty tool, or making
168
+ its draft incompatible with new controls, rejects the refresh with a diagnostic. Save/discard
169
+ that draft to retry. Invalid updates do not replace the last accepted registry. If a deleted
170
+ source is still imported by that registry, restore it before resolving the draft; Next may show
171
+ a temporary module-not-found error while a removed tool is being rediscovered.
172
+
173
+ Mount the editor route once (init creates it):
174
+
175
+ ```tsx
176
+ // src/app/studio/page.tsx
177
+ 'use client'
178
+ import '@joycostudio/studio/styles.css'
179
+ import { Studio } from '@joycostudio/studio/react'
180
+ import { studio } from '../../studio'
181
+
182
+ export default function StudioPage() {
183
+ return <Studio studio={studio} />
184
+ }
89
185
  ```
90
186
 
187
+ ### Existing manual integrations
188
+
189
+ Explicit `defineTool({ id, file, initial, controls, preview })` definitions and
190
+ `defineConfig({ tools: [...] })` remain supported. An explicit tools list disables discovery;
191
+ do not combine `tools` with `toolsDir`. To migrate, move the declarations into `studio.tool.ts`,
192
+ keep their IDs, replace `file`/`initial` with an optional `config` path, and default-export them.
193
+ Point the client at the generated registry and wrap Next config with `withStudio`.
194
+ For manual setups, install `@joycostudio/studio`, `@joycostudio/xyz`, and `tweakpane` in the app.
195
+
91
196
  Live edits also work without `studio dev`, including in production. Studio shares configs between
92
197
  same-origin previews and tabs in the same browser using `BroadcastChannel`. Peers match by tool ID,
93
198
  config file, and runtime URL (or no runtime); a newly opened or reloaded preview receives the current
94
199
  config from an open peer. Nothing is persisted: closing every page resets these edits to the shipped
95
- config. Save, Discard, and MCP still require a runtime. If a configured runtime disconnects, browser
96
- sync continues; reconnecting restores the runtime's draft.
200
+ config. Discard works the same either way: it resets the draft to the saved config (the runtime's last
201
+ reported one, or the shipped config) and syncs like any edit. Save and MCP require a runtime. If a configured
202
+ runtime disconnects, browser sync continues; reconnecting restores the runtime's draft.
203
+
204
+ Floating debug pane, with the same controls and save/discard actions:
205
+
206
+ ```tsx
207
+ // Mount once in a client component that imports your studio instance.
208
+ import { StudioDebug } from '@joycostudio/studio/react'
97
209
 
98
- Floating debug pane, with the same controls (plus save/discard when the runtime is up):
210
+ ;<StudioDebug studio={studio} />
211
+ ```
212
+
213
+ Studio owns lazy pane creation, frame refresh, registration, and cleanup through Xyz's
214
+ DebugManager. `?debug` and Option+D keep their existing behavior. The pane and command menu
215
+ are optional; neither is needed for `/studio`.
216
+
217
+ The Next.js adapter reads the endpoint passed by `studio dev`, including port overrides and
218
+ collision fallback. No `next.config` changes or hand-written environment variables are needed.
219
+ It defaults to browser-only mode without the CLI or in production. Restart the dev server when
220
+ changing runtime settings. Other frameworks can use the explicit client API:
99
221
 
100
222
  ```ts
101
- import { everyFrame } from '@joycostudio/studio/client'
102
- import { DebugManager } from '@joycostudio/xyz/debug'
103
- import { Pane } from 'tweakpane'
223
+ import { createStudio } from '@joycostudio/studio/client'
224
+ import config from '../studio.config'
104
225
 
105
- const debug = new DebugManager({ createPane: () => new Pane(), binding: { registerFrame: everyFrame } })
106
- for (const tool of studio.tools) debug.register(tool)
226
+ export const studio = createStudio(config, { runtime: 'ws://127.0.0.1:5179' })
227
+ // Pass runtime: false for browser-only operation.
107
228
  ```
108
229
 
230
+ Existing `createStudio({ tools, runtime })` calls remain supported. Both entry points accept
231
+ an explicit runtime override, including `false`.
232
+
109
233
  The `/studio` route:
110
234
 
111
235
  ```tsx
112
236
  // app/studio/page.tsx
113
237
  'use client'
238
+ import '@joycostudio/studio/styles.css'
114
239
  import { Studio } from '@joycostudio/studio/react'
115
240
  import { studio } from '@/studio'
116
241
 
@@ -119,6 +244,10 @@ export default function StudioPage() {
119
244
  }
120
245
  ```
121
246
 
247
+ Studio and the command menu render in their own shadow roots and bundle their styles, so neither needs a
248
+ stylesheet import. Host page CSS (global resets, `!important` rules, theme variables such as shadcn's
249
+ `--foreground`, or a resized `<html>` font) can't change them, and their styles never reach the host page.
250
+
122
251
  Use the pin button beside a sidebar tool to keep it at the top. Pinned tools also appear first in the
123
252
  **Command/Ctrl+K** menu. Pins are saved in this browser for the current origin and shared across tabs;
124
253
  unpinning restores registration order. Pinning a tool keeps the current preview selected.
@@ -148,14 +277,16 @@ in the same tab. New tabs start with panels shown and the canvas contained.
148
277
 
149
278
  ### Shared canvas and panel components
150
279
 
151
- Import these from `@joycostudio/studio/react` with `@joycostudio/studio/styles.css`:
280
+ Import these from `@joycostudio/studio/react`. Inside Studio (for example, in a `previewComponent`) they
281
+ are already styled; to use them on your own pages, such as a custom-layout tool preview, also import
282
+ `@joycostudio/studio/styles.css` there:
152
283
 
153
284
  | Component | Use |
154
285
  | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
155
286
  | `StudioSurface`, `StudioToolbar` | Shared inset card and compact action row, including button states. |
156
287
  | `CanvasToolbar` | Pinned/auto-hide header; `label`, optional `expanded`/`onToggle`, and child controls. |
157
288
  | `PreviewCanvas` | Iframe panel with reload, expansion, and a `toolbar` slot replacing the title. |
158
- | `StudioSelect`, `StudioSelectItem` | Labeled Radix select with Studio field and portaled menu styling. |
289
+ | `StudioSelect`, `StudioSelectItem` | Labeled Base UI select with Studio field and portaled menu styling. |
159
290
  | `StudioInput` | Standard input with logical padding (`ps-*`/`pe-*`) for icon-bearing fields. |
160
291
  | `StudioPanel`, `StudioPanelHeader`, `StudioPanelContent`, `StudioPanelFooter` | Floating panel surface, inset sections, and scrolling body used by Studio's inspector. |
161
292
 
@@ -173,7 +304,9 @@ these imports. Module graphs, tracing, and scene-specific controls remain host-o
173
304
 
174
305
  `Studio` accepts `previewComponent` and `getConfigFileHref`, so integrations no longer need to patch
175
306
  the built package. Keep a custom preview component at module scope and forward its iframe ref and
176
- props; Studio uses them for keyboard shortcuts, comparison, and tool-switch lifecycle:
307
+ props; Studio uses them for keyboard shortcuts, comparison, and tool-switch lifecycle. The component
308
+ renders inside Studio's shadow root, so host stylesheets don't apply to it: build its chrome from the
309
+ shared components below (Studio's Tailwind utilities are available too) or inline styles.
177
310
 
178
311
  ```tsx
179
312
  import { forwardRef, type ComponentPropsWithoutRef } from 'react'
@@ -211,7 +344,6 @@ Mount `StudioCommandMenu` once in the host app's root layout so it stays availab
211
344
  ```tsx
212
345
  // src/command-menu.tsx
213
346
  'use client'
214
- import '@joycostudio/studio/command-menu.css'
215
347
  import { StudioCommandMenu } from '@joycostudio/studio/react'
216
348
  import { studio } from './studio'
217
349
 
@@ -221,7 +353,8 @@ export function CommandMenu() {
221
353
  ```
222
354
 
223
355
  Render `<CommandMenu />` alongside the layout's `{children}`. The host owns mounting it, just like the floating debug pane.
224
- Its stylesheet is scoped to the menu and can be imported globally without applying Studio's page styles to the host app.
356
+ It renders in its own shadow root with its styles bundled, so it needs no stylesheet import and is unaffected by the
357
+ host's CSS. (`@joycostudio/studio/command-menu.css` is now empty and only kept so existing imports resolve.)
225
358
 
226
359
  Press **Command+K** (Mac) or **Ctrl+K** to open or close it, type a tool name or ID to search, use **↑/↓** and **Enter** to
227
360
  select, or **Escape** to dismiss. Clicking a result also opens it. Results link to `/studio#tool-id`; switching tools from
@@ -3,10 +3,11 @@ var hasConfig = (tool) => tool.controls !== void 0;
3
3
  var ID = /^[a-z0-9][a-z0-9-]*$/;
4
4
  function defineTool(tool) {
5
5
  if (!ID.test(tool.id)) throw new Error(`[studio] tool id "${tool.id}" must be kebab-case`);
6
- const config = [tool.file, tool.initial, tool.controls].filter((part) => part !== void 0).length;
7
- if (config === 1 || config === 2)
8
- throw new Error(`[studio] tool "${tool.id}" needs all of file, initial, and controls, or none of them`);
9
- if (!config && !tool.preview) throw new Error(`[studio] tool "${tool.id}" needs a preview or a config`);
6
+ const value = tool;
7
+ if (value.file !== void 0 || value.initial !== void 0) {
8
+ if (value.file === void 0 || value.initial === void 0 || value.controls === void 0)
9
+ throw new Error(`[studio] tool "${tool.id}" needs all of file, initial, and controls, or none of them`);
10
+ }
10
11
  return tool;
11
12
  }
12
13
  var DEFAULT_PORT = 5179;
@@ -97,4 +98,4 @@ export {
97
98
  error,
98
99
  validateConfig
99
100
  };
100
- //# sourceMappingURL=chunk-OIMLF2CL.js.map
101
+ //# sourceMappingURL=chunk-DEJYGBPJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/tool.ts","../src/core/result.ts","../src/core/validate.ts"],"sourcesContent":["import type { DebugSchema } from '@joycostudio/xyz/debug'\n\n/**\n * An SVG icon as data: `[tag, attributes]` children of a 24×24 stroke icon.\n * Lucide's core package (`import { Palette } from 'lucide'`) exports exactly this shape.\n */\nexport type ToolIcon = readonly (readonly [tag: string, attrs: Record<string, string | number | undefined>])[]\n\ntype ToolBase = {\n /** Unique, URL-safe identifier. */\n id: string\n title?: string\n /** Shown next to the tool in /studio. Data, not a component, so contracts stay loadable in Node. */\n icon?: ToolIcon\n /** App route that renders this tool's result, shown next to the controls in /studio. */\n preview?: string\n /** Use a bare iframe for tools that already own their canvas layout and headers. */\n previewLayout?: 'canvas' | 'custom'\n}\n\n/**\n * A tool's contract. Isomorphic on purpose: the runtime loads it in Node to\n * validate and persist, the app loads it in the browser to apply and debug.\n *\n * `TConfig` is the one type that is read, edited, applied, and persisted.\n */\nexport type ToolDefinition<TConfig extends object = object> = ToolBase & {\n /** Repo-relative JSON file the config persists to. Import that same file as `initial`. */\n file: string\n /** Config used before the runtime answers (and in production). */\n initial: TConfig\n /**\n * What can be tweaked, as an Xyz/Tweakpane schema bound to `config`.\n * Doubles as the runtime validator: every control's bounds are enforced on write.\n */\n controls: (config: TConfig) => DebugSchema\n}\n\n/** A tool with nothing to tune: a sidebar entry for its preview, without an inspector or config file. */\nexport type PreviewToolDefinition = ToolBase & {\n preview: string\n file?: never\n initial?: never\n controls?: never\n}\n\n/** A tool with a config. */\nexport type AnyTool = ToolDefinition<any>\n\n/** Anything `defineTool` returns: the kind follows from whether it declares a config. */\nexport type StudioTool = AnyTool | PreviewToolDefinition\n\nexport const hasConfig = (tool: StudioTool): tool is AnyTool => tool.controls !== undefined\n\n/** Filled by .studio/types.generated.ts in each consuming project. */\nexport interface DiscoveredToolConfigs {}\n\n/** A discovered declaration is resolved into a full definition by the generated registry. */\nexport type DiscoveredToolDefinition<TConfig extends object = object> = ToolBase & {\n /** JSON source relative to this tool file, or a tsconfig path alias. Defaults to ./config.json. */\n config?: string\n controls: (config: TConfig) => DebugSchema\n file?: never\n initial?: never\n}\nexport type ToolReference<TConfig extends object> = Pick<ToolDefinition<TConfig>, 'id' | 'controls'>\nexport type ConfigOf<TTool> = TTool extends ToolReference<infer TConfig> ? TConfig : never\n\nconst ID = /^[a-z0-9][a-z0-9-]*$/\n\n/** Discovered tools infer their config from the generated ID-to-JSON type map. */\nexport function defineTool<K extends keyof DiscoveredToolConfigs & string>(\n tool: Omit<DiscoveredToolDefinition<Extract<DiscoveredToolConfigs[K], object>>, 'id'> & { id: K },\n): DiscoveredToolDefinition<Extract<DiscoveredToolConfigs[K], object>>\n/** Existing explicitly registered tools remain supported. */\nexport function defineTool<TConfig extends object>(tool: ToolDefinition<TConfig>): ToolDefinition<TConfig>\nexport function defineTool(tool: PreviewToolDefinition): PreviewToolDefinition\n/** A discovered preview-only tool may infer its preview from the adjacent page. */\nexport function defineTool(\n tool: ToolBase & { controls?: never; config?: never; file?: never; initial?: never },\n): ToolBase\nexport function defineTool(tool: StudioTool | DiscoveredToolDefinition<any> | ToolBase) {\n if (!ID.test(tool.id)) throw new Error(`[studio] tool id \"${tool.id}\" must be kebab-case`)\n const value = tool as Partial<AnyTool>\n if (value.file !== undefined || value.initial !== undefined) {\n if (value.file === undefined || value.initial === undefined || value.controls === undefined)\n throw new Error(`[studio] tool \"${tool.id}\" needs all of file, initial, and controls, or none of them`)\n }\n return tool\n}\n\nexport type StudioConfig = {\n /** Explicit registry; omit to enable convention-based discovery. */\n tools?: readonly StudioTool[]\n /**\n * Directory to scan and watch for `studio.tool.ts` files, relative to\n * the project root.\n *\n * Defaults to the active Next.js app directory: `app/` or `src/app/`.\n * Set a narrower directory to reduce scanning and discovery triggers.\n * This does not change preview URLs.\n *\n * @example 'src/app/experiments'\n */\n toolsDir?: string\n /** Runtime WebSocket and HTTP MCP port. Defaults to 5179. */\n port?: number\n /** Browser origins allowed on the bridge and MCP endpoint. Defaults to localhost origins. */\n origins?: readonly string[]\n /** Running app URL (e.g. http://localhost:3000), used for MCP preview captures. */\n appUrl?: string\n}\n\nexport const DEFAULT_PORT = 5179\n\nexport function defineConfig<T extends StudioConfig>(config: T): T {\n return config\n}\n","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","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":[]}