@joycostudio/studio 0.1.0 → 0.1.2

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/dist/client.js CHANGED
@@ -1,9 +1,26 @@
1
1
  import {
2
- error
3
- } from "./chunk-QKEAOM2U.js";
2
+ error,
3
+ validateConfig
4
+ } from "./chunk-X4YLM5HA.js";
4
5
 
5
6
  // src/client/studio.ts
6
- import { button } from "@joycostudio/xyz/debug";
7
+ import {
8
+ button,
9
+ schemaValues
10
+ } from "@joycostudio/xyz/debug";
11
+
12
+ // src/core/diff.ts
13
+ function diff(before, after, path = []) {
14
+ const nested = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
+ if (nested(before) && nested(after)) {
16
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
17
+ return [...keys].flatMap((key) => diff(before[key], after[key], [...path, key]));
18
+ }
19
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ path, before, after }];
20
+ }
21
+ function changedPaths(before, after) {
22
+ return diff(before, after).map(({ path }) => path.join("."));
23
+ }
7
24
 
8
25
  // src/client/bridge.ts
9
26
  function connectBridge(url, handlers) {
@@ -52,6 +69,110 @@ function connectBridge(url, handlers) {
52
69
  };
53
70
  }
54
71
 
72
+ // src/client/browser-sync.ts
73
+ function connectBrowserSync(definition, runtime, handlers) {
74
+ if (typeof window === "undefined" || !window.location?.origin || !window.BroadcastChannel) return null;
75
+ let channel;
76
+ try {
77
+ channel = new window.BroadcastChannel(
78
+ JSON.stringify(["joyco-studio:config", runtime || null, definition.id, definition.file])
79
+ );
80
+ } catch {
81
+ return null;
82
+ }
83
+ const sender = crypto.getRandomValues(new Uint32Array(4)).join("-");
84
+ let clock = 0;
85
+ let latest;
86
+ let closed = false;
87
+ channel.onmessage = ({ data }) => {
88
+ if (closed || !data || typeof data !== "object") return;
89
+ if (data.type === "request") {
90
+ if (latest) channel.postMessage(latest);
91
+ return;
92
+ }
93
+ if (data.type !== "config" || !Number.isSafeInteger(data.clock) || data.clock < 1 || typeof data.sender !== "string")
94
+ return;
95
+ clock = Math.max(clock, data.clock);
96
+ if (!handlers.enabled()) return;
97
+ if (latest && (data.clock < latest.clock || data.clock === latest.clock && data.sender <= latest.sender)) return;
98
+ const result = validateConfig(definition, data.value);
99
+ if (!result.ok) return;
100
+ latest = { type: "config", clock: data.clock, sender: data.sender, value: result.data };
101
+ handlers.receive(result.data);
102
+ };
103
+ channel.postMessage({ type: "request" });
104
+ return {
105
+ publish(value) {
106
+ if (closed) return;
107
+ latest = { type: "config", clock: ++clock, sender, value: structuredClone(value) };
108
+ channel.postMessage(latest);
109
+ },
110
+ close() {
111
+ closed = true;
112
+ channel.onmessage = null;
113
+ channel.close();
114
+ }
115
+ };
116
+ }
117
+
118
+ // src/client/history.ts
119
+ var LIMIT = 100;
120
+ var MERGE_MS = 500;
121
+ function createHistory() {
122
+ const past = [];
123
+ let future = [];
124
+ let open = null;
125
+ const step = (from, to, side) => {
126
+ const entry = from.pop();
127
+ if (!entry) return null;
128
+ to.push(entry);
129
+ open = null;
130
+ return entry.changes.map((change) => ({ path: change.path, value: change[side] }));
131
+ };
132
+ return {
133
+ get canUndo() {
134
+ return past.length > 0;
135
+ },
136
+ get canRedo() {
137
+ return future.length > 0;
138
+ },
139
+ /** Record the edit between two snapshots. `merge: false` keeps it a step of its own (e.g. Discard). */
140
+ record(before, after, { merge = true } = {}) {
141
+ const changes = structuredClone(diff(before, after));
142
+ if (!changes.length) return;
143
+ future = [];
144
+ const key = JSON.stringify(changes.map(({ path }) => path));
145
+ const now = Date.now();
146
+ if (merge && open && open.key === key && now - open.at < MERGE_MS) {
147
+ open.changes = open.changes.map((change, i) => ({ ...change, after: changes[i].after }));
148
+ open.at = now;
149
+ if (open.changes.every((change) => JSON.stringify(change.before) === JSON.stringify(change.after))) {
150
+ past.pop();
151
+ open = null;
152
+ }
153
+ return;
154
+ }
155
+ const entry = { changes, key, at: now };
156
+ past.push(entry);
157
+ if (past.length > LIMIT) past.shift();
158
+ open = merge ? entry : null;
159
+ },
160
+ /** The leaves to write back, or null when there is nothing to undo. */
161
+ undo: () => step(past, future, "before"),
162
+ redo: () => step(future, past, "after")
163
+ };
164
+ }
165
+ function writePath(target, path, value) {
166
+ const key = path.at(-1);
167
+ let parent = target;
168
+ for (const segment of path.slice(0, -1)) {
169
+ const next = parent[segment];
170
+ parent = typeof next === "object" && next !== null ? next : parent[segment] = {};
171
+ }
172
+ if (value === void 0) delete parent[key];
173
+ else parent[key] = structuredClone(value);
174
+ }
175
+
55
176
  // src/client/live.ts
56
177
  function live(target, onWrite) {
57
178
  const proxies = /* @__PURE__ */ new WeakMap();
@@ -105,6 +226,23 @@ function liveSchema(schema) {
105
226
  })
106
227
  );
107
228
  }
229
+ function resetSlidersOnDoubleClick(handle) {
230
+ return (folder, { fields }) => {
231
+ const sliders = fields().filter(({ field }) => field.kind === "slider");
232
+ const onDoubleClick = (event) => {
233
+ if (handle.comparing || event.target.closest("input")) return;
234
+ const slider = sliders.find(({ blade }) => blade.element.contains(event.target));
235
+ if (slider?.field.kind !== "slider") return;
236
+ const saved = slider.path.reduce(
237
+ (values, key) => values?.[key],
238
+ schemaValues(handle.definition.controls(structuredClone(handle.saved)))
239
+ );
240
+ if (typeof saved === "number") slider.field.write(saved);
241
+ };
242
+ folder.element.addEventListener("dblclick", onDoubleClick);
243
+ return () => folder.element.removeEventListener("dblclick", onDoubleClick);
244
+ };
245
+ }
108
246
  function everyFrame(refresh) {
109
247
  let frame = requestAnimationFrame(function tick() {
110
248
  refresh();
@@ -114,6 +252,7 @@ function everyFrame(refresh) {
114
252
  }
115
253
  var instances = globalThis.__joycoStudio ??= /* @__PURE__ */ new Map();
116
254
  function createStudio({ tools, runtime }) {
255
+ const instanceKey = runtime || JSON.stringify(tools.map(({ id, file }) => [id, file]).sort());
117
256
  let bridge = null;
118
257
  let connection = { phase: runtime ? "connecting" : "local" };
119
258
  let version = 0;
@@ -128,9 +267,13 @@ function createStudio({ tools, runtime }) {
128
267
  let pushing = Promise.resolve();
129
268
  let scheduled = false;
130
269
  let stashed = null;
270
+ const history = createHistory();
271
+ let snapshot = structuredClone(raw);
131
272
  const changed = () => {
273
+ snapshot = structuredClone(raw);
132
274
  handle.version++;
133
275
  for (const listener of listeners) listener(handle.config);
276
+ touch();
134
277
  };
135
278
  const adopt = (state) => {
136
279
  handle.state = state;
@@ -141,6 +284,7 @@ function createStudio({ tools, runtime }) {
141
284
  else assignDeep(raw, state.draft.value);
142
285
  adopt(state);
143
286
  if (!stashed) changed();
287
+ browserSync?.publish(state.draft.value);
144
288
  };
145
289
  const resync = async () => {
146
290
  const result = await bridge?.request("tool.get", { tool: definition.id });
@@ -160,25 +304,54 @@ function createStudio({ tools, runtime }) {
160
304
  }
161
305
  });
162
306
  };
307
+ const edited = () => {
308
+ changed();
309
+ if (!stashed) {
310
+ browserSync?.publish(raw);
311
+ push();
312
+ }
313
+ };
314
+ const restore = (patch) => {
315
+ if (!patch) return false;
316
+ for (const { path, value } of patch) writePath(raw, path, value);
317
+ edited();
318
+ return true;
319
+ };
163
320
  const handle = {
164
321
  definition,
165
322
  config: live(raw, () => {
166
- changed();
167
- if (!stashed) push();
323
+ if (!stashed) history.record(snapshot, raw);
324
+ edited();
168
325
  }),
169
326
  state: null,
170
327
  version: 0,
328
+ get saved() {
329
+ return handle.state?.saved.value ?? definition.initial;
330
+ },
331
+ get changes() {
332
+ return changedPaths(handle.saved, stashed ?? raw);
333
+ },
334
+ get dirty() {
335
+ return handle.changes.length > 0;
336
+ },
171
337
  comparing: false,
172
338
  compare(showSaved) {
173
- if (showSaved === handle.comparing || showSaved && !handle.state) return;
339
+ if (showSaved === handle.comparing) return;
174
340
  if (showSaved) stashed = structuredClone(raw);
175
- const next = showSaved ? handle.state.saved.value : stashed;
341
+ const next = showSaved ? handle.saved : stashed;
176
342
  assignDeep(raw, next);
177
343
  if (!showSaved) stashed = null;
178
344
  handle.comparing = showSaved;
179
345
  changed();
180
- touch();
181
346
  },
347
+ get canUndo() {
348
+ return history.canUndo;
349
+ },
350
+ get canRedo() {
351
+ return history.canRedo;
352
+ },
353
+ undo: () => !stashed && restore(history.undo()),
354
+ redo: () => !stashed && restore(history.redo()),
182
355
  subscribe(listener) {
183
356
  listeners.add(listener);
184
357
  return () => void listeners.delete(listener);
@@ -191,7 +364,8 @@ function createStudio({ tools, runtime }) {
191
364
  } : {};
192
365
  return {
193
366
  title: definition.title ?? definition.id,
194
- schema: () => ({ ...liveSchema(handle.controls()), ...actions })
367
+ schema: () => ({ ...liveSchema(handle.controls()), ...actions }),
368
+ extend: resetSlidersOnDoubleClick(handle)
195
369
  };
196
370
  },
197
371
  async save({ force = false } = {}) {
@@ -206,13 +380,30 @@ function createStudio({ tools, runtime }) {
206
380
  async discard() {
207
381
  if (!bridge) return error("runtime_unavailable");
208
382
  await pushing;
383
+ const before = snapshot;
209
384
  const result = await bridge.request("tool.discard", { tool: definition.id });
210
- if (result.ok) receive(result.data);
385
+ if (result.ok) {
386
+ receive(result.data);
387
+ if (!stashed) history.record(before, raw, { merge: false });
388
+ }
211
389
  return result;
212
390
  },
213
391
  receive,
214
- resync
392
+ resync,
393
+ close: () => browserSync?.close()
215
394
  };
395
+ const browserSync = connectBrowserSync(definition, runtime, {
396
+ enabled: () => connection.phase !== "connected",
397
+ receive(value) {
398
+ if (stashed) {
399
+ stashed = value;
400
+ touch();
401
+ } else {
402
+ assignDeep(raw, value);
403
+ changed();
404
+ }
405
+ }
406
+ });
216
407
  return handle;
217
408
  };
218
409
  const handles = new Map(tools.map((definition) => [definition.id, createHandle(definition)]));
@@ -251,14 +442,15 @@ function createStudio({ tools, runtime }) {
251
442
  },
252
443
  close() {
253
444
  bridge?.close();
445
+ for (const handle of handles.values()) handle.close();
254
446
  studioListeners.clear();
255
447
  if (typeof window !== "undefined") window.removeEventListener?.("message", onMessage);
256
- if (runtime && instances.get(runtime) === studio) instances.delete(runtime);
448
+ if (instances.get(instanceKey) === studio) instances.delete(instanceKey);
257
449
  }
258
450
  };
259
- if (bridge && runtime) {
260
- instances.get(runtime)?.close();
261
- instances.set(runtime, studio);
451
+ if (typeof window !== "undefined") {
452
+ instances.get(instanceKey)?.close();
453
+ instances.set(instanceKey, studio);
262
454
  }
263
455
  return studio;
264
456
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/client/studio.ts","../src/client/bridge.ts","../src/client/live.ts"],"sourcesContent":["import { button, type DebugField, type DebugFolder, type DebugSchema } from '@joycostudio/xyz/debug'\nimport type { ToolState } from '../core/protocol'\nimport { error, type Result } from '../core/result'\nimport type { AnyTool, ToolDefinition } from '../core/tool'\nimport { connectBridge, type BridgeClient, type Connection } from './bridge'\nimport { assignDeep, live } from './live'\n\nexport type ToolHandle<TConfig extends object = any> = {\n readonly definition: ToolDefinition<TConfig>\n /** The live config. Read it to apply; writes (from any pane, or your code) sync to the runtime. */\n readonly config: TConfig\n /** Runtime-owned state; null until the runtime answers (always null without one). */\n readonly state: ToolState<TConfig> | null\n /** Bumps on every config change. Handy as a React snapshot. */\n readonly version: number\n /** Called after every config change, local or remote. */\n subscribe(listener: (config: TConfig) => void): () => void\n /** The tool's Xyz schema bound to the live config. */\n controls(): DebugSchema\n /** Xyz `Debuggable`: `debugManager.register(handle)` mounts this tool in the app's floating pane. */\n debug(): DebugFolder\n /** True while `compare(true)` shows the saved config in place of the draft. */\n readonly comparing: boolean\n /**\n * Temporarily show the saved config (true) or go back to the draft (false). Local only: nothing\n * reaches the runtime, and edits made while comparing are dropped when it ends.\n */\n compare(showSaved: boolean): void\n save(options?: { force?: boolean }): Promise<Result<ToolState<TConfig>>>\n discard(): Promise<Result<ToolState<TConfig>>>\n}\n\nexport type Studio = {\n readonly tools: readonly ToolHandle[]\n tool<TConfig extends object>(definition: ToolDefinition<TConfig>): ToolHandle<TConfig>\n readonly connection: Connection\n /** Bumps on connection or any tool state change. */\n readonly version: number\n subscribe(listener: () => void): () => void\n close(): void\n}\n\nexport type StudioOptions = {\n tools: readonly AnyTool[]\n /**\n * Runtime bridge URL (e.g. `ws://127.0.0.1:5179`). Omit or pass false to stay local, as in production.\n * Only browsers connect, so a module-scope `createStudio` is safe to evaluate during SSR.\n */\n runtime?: string | false\n}\n\nconst COMPARE_MESSAGE = 'joyco-studio:compare'\ntype CompareMessage = { type: typeof COMPARE_MESSAGE; tool: string; showSaved: boolean }\n\n/** Make the studio running in `target` (e.g. a preview iframe) compare `tool` as well. */\nexport function postCompare(target: Window | null | undefined, tool: string, showSaved: boolean) {\n const message: CompareMessage = { type: COMPARE_MESSAGE, tool, showSaved }\n target?.postMessage(message, location.origin)\n}\n\n/** Mark every value control live so a pane registered with `registerFrame` reflects remote edits. */\nfunction liveSchema(schema: DebugSchema): DebugSchema {\n return Object.fromEntries(\n Object.entries(schema).map(([key, field]): [string, DebugField] => {\n if (field.kind === 'button') return [key, field]\n if (field.kind === 'folder')\n return [\n key,\n { ...field, definition: { ...field.definition, schema: () => liveSchema(field.definition.schema()) } },\n ]\n return [key, { ...field, live: true }]\n }),\n )\n}\n\n/** A `registerFrame` for Xyz's `DebugManager` / `bindDebugSchema`: refreshes live controls every animation frame. */\nexport function everyFrame(refresh: () => void) {\n let frame = requestAnimationFrame(function tick() {\n refresh()\n frame = requestAnimationFrame(tick)\n })\n return () => cancelAnimationFrame(frame)\n}\n\n/** One studio per runtime URL per page: HMR re-evaluating `createStudio` replaces the previous instance instead of leaking its socket. */\nconst instances: Map<string, Studio> = ((globalThis as { __joycoStudio?: Map<string, Studio> }).__joycoStudio ??=\n new Map())\n\nexport function createStudio({ tools, runtime }: StudioOptions): Studio {\n let bridge: BridgeClient | null = null\n let connection: Connection = { phase: runtime ? 'connecting' : 'local' }\n let version = 0\n const studioListeners = new Set<() => void>()\n const touch = () => {\n version++\n for (const listener of studioListeners) listener()\n }\n\n const createHandle = <TConfig extends object>(definition: ToolDefinition<TConfig>) => {\n const raw = structuredClone(definition.initial)\n const listeners = new Set<(config: TConfig) => void>()\n let pushing: Promise<unknown> = Promise.resolve()\n let scheduled = false\n /** The draft to restore while comparing; null when showing the draft. */\n let stashed: TConfig | null = null\n\n const changed = () => {\n handle.version++\n for (const listener of listeners) listener(handle.config)\n }\n const adopt = (state: ToolState) => {\n handle.state = state as ToolState<TConfig>\n touch()\n }\n const receive = (state: ToolState) => {\n if (stashed) stashed = structuredClone(state.draft.value) as TConfig\n else assignDeep(raw as Record<string, unknown>, state.draft.value as Record<string, unknown>)\n adopt(state)\n if (!stashed) changed()\n }\n const resync = async () => {\n const result = await bridge?.request('tool.get', { tool: definition.id })\n if (result?.ok) receive(result.data)\n }\n const push = () => {\n if (scheduled || !bridge) return\n scheduled = true\n pushing = Promise.resolve().then(async () => {\n scheduled = false\n const result = await bridge?.request('tool.update', { tool: definition.id, value: raw })\n if (!result) return\n if (result.ok) adopt(result.data)\n else if (result.error !== 'runtime_unavailable') {\n console.warn(`[studio] ${definition.id}: ${result.message ?? result.error}`)\n await resync()\n }\n })\n }\n\n const handle = {\n definition,\n config: live(raw, () => {\n changed()\n if (!stashed) push()\n }),\n state: null as ToolState<TConfig> | null,\n version: 0,\n comparing: false,\n compare(showSaved: boolean) {\n if (showSaved === handle.comparing || (showSaved && !handle.state)) return\n if (showSaved) stashed = structuredClone(raw)\n const next = showSaved ? handle.state!.saved.value : stashed!\n assignDeep(raw as Record<string, unknown>, next as Record<string, unknown>)\n if (!showSaved) stashed = null\n handle.comparing = showSaved\n changed()\n touch()\n },\n subscribe(listener: (config: TConfig) => void) {\n listeners.add(listener)\n return () => void listeners.delete(listener)\n },\n controls: () => definition.controls(handle.config),\n debug(): DebugFolder {\n const actions: DebugSchema = runtime\n ? {\n save: button('Save', () => handle.save().then(fail), 'Saved ✓'),\n discard: button('Discard', () => handle.discard().then(fail), 'Discarded'),\n }\n : {}\n return {\n title: definition.title ?? definition.id,\n schema: () => ({ ...liveSchema(handle.controls()), ...actions }),\n }\n },\n async save({ force = false } = {}) {\n if (!bridge) return error('runtime_unavailable')\n await pushing\n const revision = handle.state?.draft.revision\n if (revision === undefined) return error('runtime_unavailable')\n const result = await bridge.request('tool.save', { tool: definition.id, revision, force })\n if (result.ok) adopt(result.data)\n return result as Result<ToolState<TConfig>>\n },\n async discard() {\n if (!bridge) return error('runtime_unavailable')\n await pushing\n const result = await bridge.request('tool.discard', { tool: definition.id })\n if (result.ok) receive(result.data)\n return result as Result<ToolState<TConfig>>\n },\n receive,\n resync,\n }\n return handle\n }\n\n const handles = new Map(tools.map((definition) => [definition.id, createHandle(definition)]))\n\n // /studio asks its same-origin preview iframe to compare too (see `postCompare`).\n const onMessage = (event: MessageEvent) => {\n const message = event.data as CompareMessage | undefined\n if (event.origin !== location.origin || message?.type !== COMPARE_MESSAGE) return\n handles.get(message.tool)?.compare(message.showSaved)\n }\n if (typeof window !== 'undefined') window.addEventListener?.('message', onMessage)\n\n if (runtime && typeof window !== 'undefined') {\n bridge = connectBridge(runtime, {\n state: (state) => handles.get(state.tool)?.receive(state),\n connection(next) {\n connection = next\n touch()\n if (next.phase === 'connected') for (const handle of handles.values()) void handle.resync()\n },\n })\n }\n\n const studio: Studio = {\n tools: [...handles.values()],\n tool<TConfig extends object>(definition: ToolDefinition<TConfig>) {\n const handle = handles.get(definition.id)\n if (!handle) throw new Error(`[studio] tool \"${definition.id}\" was not passed to createStudio`)\n return handle as unknown as ToolHandle<TConfig>\n },\n get connection() {\n return connection\n },\n get version() {\n return version\n },\n subscribe(listener) {\n studioListeners.add(listener)\n return () => void studioListeners.delete(listener)\n },\n close() {\n bridge?.close()\n studioListeners.clear()\n if (typeof window !== 'undefined') window.removeEventListener?.('message', onMessage)\n if (runtime && instances.get(runtime) === studio) instances.delete(runtime)\n },\n }\n if (bridge && runtime) {\n instances.get(runtime)?.close()\n instances.set(runtime, studio)\n }\n return studio\n}\n\nfunction fail(result: Result<unknown>) {\n if (!result.ok) throw new Error(result.message ?? result.error)\n}\n","import type { Op, Operations, ServerMessage, ToolState } from '../core/protocol'\nimport { error, type Result } from '../core/result'\n\nexport type Connection = { phase: 'local' } | { phase: 'connecting' } | { phase: 'connected' } | { phase: 'offline' }\n\ntype Pending = (result: Result<unknown>) => void\n\n/** A reconnecting WebSocket to the Studio runtime with request/response correlation. */\nexport function connectBridge(\n url: string,\n handlers: { state: (state: ToolState) => void; connection: (connection: Connection) => void },\n) {\n let socket: WebSocket | null = null\n let nextId = 0\n let retry = 0\n let timer: ReturnType<typeof setTimeout> | undefined\n let closed = false\n const pending = new Map<number, Pending>()\n\n const open = () => {\n handlers.connection({ phase: 'connecting' })\n const ws = new WebSocket(url)\n socket = ws\n ws.onopen = () => {\n retry = 0\n handlers.connection({ phase: 'connected' })\n }\n ws.onmessage = (event) => {\n const message = JSON.parse(String(event.data)) as ServerMessage\n if (message.type === 'state') return handlers.state(message.state)\n pending.get(message.id)?.(message.result)\n pending.delete(message.id)\n }\n ws.onclose = () => {\n socket = null\n for (const resolve of pending.values()) resolve(error('runtime_unavailable'))\n pending.clear()\n if (closed) return\n handlers.connection({ phase: 'offline' })\n timer = setTimeout(open, Math.min(500 * 2 ** retry++, 5000))\n }\n }\n open()\n\n return {\n request<TOp extends Op>(op: TOp, params: Operations[TOp]['params']): Promise<Result<Operations[TOp]['result']>> {\n if (socket?.readyState !== WebSocket.OPEN) return Promise.resolve(error('runtime_unavailable'))\n const id = nextId++\n socket.send(JSON.stringify({ id, op, params }))\n return new Promise((resolve) => pending.set(id, resolve as Pending))\n },\n close() {\n closed = true\n clearTimeout(timer)\n socket?.close()\n },\n }\n}\n\nexport type BridgeClient = ReturnType<typeof connectBridge>\n","/**\n * A deep proxy that reports every write, so plain `property(config, 'key')`\n * bindings from Xyz feed Studio without the tool author wiring callbacks.\n */\nexport function live<T extends object>(target: T, onWrite: () => void): T {\n const proxies = new WeakMap<object, object>()\n const wrap = <O extends object>(object: O): O => {\n let proxy = proxies.get(object)\n if (!proxy) {\n proxy = new Proxy(object, {\n get(target, key, receiver) {\n const value = Reflect.get(target, key, receiver)\n return typeof value === 'object' && value !== null ? wrap(value) : value\n },\n set(target, key, value, receiver) {\n const changed = Reflect.get(target, key, receiver) !== value\n const ok = Reflect.set(target, key, value, receiver)\n if (changed) onWrite()\n return ok\n },\n })\n proxies.set(object, proxy)\n }\n return proxy as O\n }\n return wrap(target)\n}\n\n/** Copy `source` into `target` in place, keeping nested object identities (and every binding to them) intact. */\nexport function assignDeep(target: Record<string, unknown>, source: Record<string, unknown>) {\n for (const [key, value] of Object.entries(source)) {\n const current = target[key]\n const nested = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v)\n if (nested(current) && nested(value))\n assignDeep(current as Record<string, unknown>, value as Record<string, unknown>)\n else target[key] = structuredClone(value)\n }\n}\n"],"mappings":"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","names":["target"]}
1
+ {"version":3,"sources":["../src/client/studio.ts","../src/core/diff.ts","../src/client/bridge.ts","../src/client/browser-sync.ts","../src/client/history.ts","../src/client/live.ts"],"sourcesContent":["import {\n button,\n type DebugDefinition,\n type DebugField,\n type DebugFolder,\n type DebugSchema,\n schemaValues,\n} from '@joycostudio/xyz/debug'\nimport { changedPaths } from '../core/diff'\nimport type { ToolState } from '../core/protocol'\nimport { error, type Result } from '../core/result'\nimport type { AnyTool, ToolDefinition } from '../core/tool'\nimport { connectBridge, type BridgeClient, type Connection } from './bridge'\nimport { connectBrowserSync } from './browser-sync'\nimport { createHistory, writePath } from './history'\nimport { assignDeep, live } from './live'\n\nexport type ToolHandle<TConfig extends object = any> = {\n readonly definition: ToolDefinition<TConfig>\n /** The live config. Writes sync to same-origin pages and, when connected, the runtime. */\n readonly config: TConfig\n /** Runtime-owned state; null until the runtime answers (always null without one). */\n readonly state: ToolState<TConfig> | null\n /** Bumps on every config change. Handy as a React snapshot. */\n readonly version: number\n /** Called after every config change, local or remote. */\n subscribe(listener: (config: TConfig) => void): () => void\n /** The tool's Xyz schema bound to the live config. */\n controls(): DebugSchema\n /** Xyz `Debuggable`: `debugManager.register(handle)` mounts this tool in the app's floating pane. */\n debug(): DebugFolder\n /** The config on disk: the runtime's saved value or, until one answers, the bundled `initial`. */\n readonly saved: TConfig\n /** Dotted paths where the draft differs from `saved`. Tracked locally, so it works without a runtime too. */\n readonly changes: string[]\n /** The draft differs from `saved`. */\n readonly dirty: boolean\n /** True while `compare(true)` shows the saved config in place of the draft. */\n readonly comparing: boolean\n /**\n * Temporarily show `saved` (true) or go back to the draft (false). Local only: nothing\n * reaches the runtime, and edits made while comparing are dropped when it ends.\n */\n compare(showSaved: boolean): void\n /** True when `undo()` has an edit made on this page to step back through. */\n readonly canUndo: boolean\n readonly canRedo: boolean\n /**\n * Step back through edits made on this page (a slider drag counts as one), then sync the result like any\n * other edit. Only the leaves each edit changed are restored. Returns false with nothing to undo or while comparing.\n */\n undo(): boolean\n redo(): boolean\n save(options?: { force?: boolean }): Promise<Result<ToolState<TConfig>>>\n discard(): Promise<Result<ToolState<TConfig>>>\n}\n\nexport type Studio = {\n readonly tools: readonly ToolHandle[]\n tool<TConfig extends object>(definition: ToolDefinition<TConfig>): ToolHandle<TConfig>\n readonly connection: Connection\n /** Bumps on connection or any tool state change. */\n readonly version: number\n subscribe(listener: () => void): () => void\n close(): void\n}\n\nexport type StudioOptions = {\n tools: readonly AnyTool[]\n /**\n * Runtime bridge URL (e.g. `ws://127.0.0.1:5179`). Omit or pass false for browser-only syncing, as in production.\n * Only browsers connect, so a module-scope `createStudio` is safe to evaluate during SSR.\n */\n runtime?: string | false\n}\n\nconst COMPARE_MESSAGE = 'joyco-studio:compare'\ntype CompareMessage = { type: typeof COMPARE_MESSAGE; tool: string; showSaved: boolean }\n\n/** Make the studio running in `target` (e.g. a preview iframe) compare `tool` as well. */\nexport function postCompare(target: Window | null | undefined, tool: string, showSaved: boolean) {\n const message: CompareMessage = { type: COMPARE_MESSAGE, tool, showSaved }\n target?.postMessage(message, location.origin)\n}\n\n/** Mark every value control live so a pane registered with `registerFrame` reflects remote edits. */\nfunction liveSchema(schema: DebugSchema): DebugSchema {\n return Object.fromEntries(\n Object.entries(schema).map(([key, field]): [string, DebugField] => {\n if (field.kind === 'button') return [key, field]\n if (field.kind === 'folder')\n return [\n key,\n { ...field, definition: { ...field.definition, schema: () => liveSchema(field.definition.schema()) } },\n ]\n return [key, { ...field, live: true }]\n }),\n )\n}\n\n/**\n * Xyz `extend` for a folder bound to `handle.controls()` (optionally followed by more fields, as in `debug()`):\n * double-clicking a slider resets it to its saved value.\n */\nexport function resetSlidersOnDoubleClick(handle: ToolHandle): NonNullable<DebugDefinition['extend']> {\n return (folder, { fields }) => {\n const sliders = fields().filter(({ field }) => field.kind === 'slider')\n const onDoubleClick = (event: MouseEvent) => {\n // Double-clicking the number input selects its text instead.\n if (handle.comparing || (event.target as Element).closest('input')) return\n const slider = sliders.find(({ blade }) => blade.element.contains(event.target as Node))\n if (slider?.field.kind !== 'slider') return\n const saved = slider.path.reduce<unknown>(\n (values, key) => (values as Record<string, unknown> | undefined)?.[key],\n schemaValues(handle.definition.controls(structuredClone(handle.saved))),\n )\n if (typeof saved === 'number') slider.field.write(saved)\n }\n folder.element.addEventListener('dblclick', onDoubleClick)\n return () => folder.element.removeEventListener('dblclick', onDoubleClick)\n }\n}\n\n/** A `registerFrame` for Xyz's `DebugManager` / `bindDebugSchema`: refreshes live controls every animation frame. */\nexport function everyFrame(refresh: () => void) {\n let frame = requestAnimationFrame(function tick() {\n refresh()\n frame = requestAnimationFrame(tick)\n })\n return () => cancelAnimationFrame(frame)\n}\n\n/** One studio per runtime URL (or local tool set) per page; replace HMR instances without leaking transports. */\nconst instances: Map<string, Studio> = ((globalThis as { __joycoStudio?: Map<string, Studio> }).__joycoStudio ??=\n new Map())\n\nexport function createStudio({ tools, runtime }: StudioOptions): Studio {\n const instanceKey = runtime || JSON.stringify(tools.map(({ id, file }) => [id, file]).sort())\n let bridge: BridgeClient | null = null\n let connection: Connection = { phase: runtime ? 'connecting' : 'local' }\n let version = 0\n const studioListeners = new Set<() => void>()\n const touch = () => {\n version++\n for (const listener of studioListeners) listener()\n }\n\n const createHandle = <TConfig extends object>(definition: ToolDefinition<TConfig>) => {\n const raw = structuredClone(definition.initial)\n const listeners = new Set<(config: TConfig) => void>()\n let pushing: Promise<unknown> = Promise.resolve()\n let scheduled = false\n /** The draft to restore while comparing; null when showing the draft. */\n let stashed: TConfig | null = null\n const history = createHistory()\n /** A copy of `raw` as of the last change, so the next local edit can be diffed against it. */\n let snapshot = structuredClone(raw)\n\n const changed = () => {\n snapshot = structuredClone(raw)\n handle.version++\n for (const listener of listeners) listener(handle.config)\n touch()\n }\n const adopt = (state: ToolState) => {\n handle.state = state as ToolState<TConfig>\n touch()\n }\n const receive = (state: ToolState) => {\n if (stashed) stashed = structuredClone(state.draft.value) as TConfig\n else assignDeep(raw as Record<string, unknown>, state.draft.value as Record<string, unknown>)\n adopt(state)\n if (!stashed) changed()\n browserSync?.publish(state.draft.value as TConfig)\n }\n const resync = async () => {\n const result = await bridge?.request('tool.get', { tool: definition.id })\n if (result?.ok) receive(result.data)\n }\n const push = () => {\n if (scheduled || !bridge) return\n scheduled = true\n pushing = Promise.resolve().then(async () => {\n scheduled = false\n const result = await bridge?.request('tool.update', { tool: definition.id, value: raw })\n if (!result) return\n if (result.ok) adopt(result.data)\n else if (result.error !== 'runtime_unavailable') {\n console.warn(`[studio] ${definition.id}: ${result.message ?? result.error}`)\n await resync()\n }\n })\n }\n\n const edited = () => {\n changed()\n if (!stashed) {\n browserSync?.publish(raw)\n push()\n }\n }\n const restore = (patch: ReturnType<typeof history.undo>) => {\n if (!patch) return false\n for (const { path, value } of patch) writePath(raw as Record<string, unknown>, path, value)\n edited()\n return true\n }\n\n const handle = {\n definition,\n config: live(raw, () => {\n if (!stashed) history.record(snapshot, raw)\n edited()\n }),\n state: null as ToolState<TConfig> | null,\n version: 0,\n get saved(): TConfig {\n return handle.state?.saved.value ?? definition.initial\n },\n get changes() {\n return changedPaths(handle.saved, stashed ?? raw)\n },\n get dirty() {\n return handle.changes.length > 0\n },\n comparing: false,\n compare(showSaved: boolean) {\n if (showSaved === handle.comparing) return\n if (showSaved) stashed = structuredClone(raw)\n const next = showSaved ? handle.saved : stashed!\n assignDeep(raw as Record<string, unknown>, next as Record<string, unknown>)\n if (!showSaved) stashed = null\n handle.comparing = showSaved\n changed()\n },\n get canUndo() {\n return history.canUndo\n },\n get canRedo() {\n return history.canRedo\n },\n undo: () => !stashed && restore(history.undo()),\n redo: () => !stashed && restore(history.redo()),\n subscribe(listener: (config: TConfig) => void) {\n listeners.add(listener)\n return () => void listeners.delete(listener)\n },\n controls: () => definition.controls(handle.config),\n debug(): DebugFolder {\n const actions: DebugSchema = runtime\n ? {\n save: button('Save', () => handle.save().then(fail), 'Saved ✓'),\n discard: button('Discard', () => handle.discard().then(fail), 'Discarded'),\n }\n : {}\n return {\n title: definition.title ?? definition.id,\n schema: () => ({ ...liveSchema(handle.controls()), ...actions }),\n extend: resetSlidersOnDoubleClick(handle),\n }\n },\n async save({ force = false } = {}) {\n if (!bridge) return error('runtime_unavailable')\n await pushing\n const revision = handle.state?.draft.revision\n if (revision === undefined) return error('runtime_unavailable')\n const result = await bridge.request('tool.save', { tool: definition.id, revision, force })\n if (result.ok) adopt(result.data)\n return result as Result<ToolState<TConfig>>\n },\n async discard() {\n if (!bridge) return error('runtime_unavailable')\n await pushing\n const before = snapshot\n const result = await bridge.request('tool.discard', { tool: definition.id })\n if (result.ok) {\n receive(result.data)\n // Discarding is undoable, as its own step.\n if (!stashed) history.record(before, raw, { merge: false })\n }\n return result as Result<ToolState<TConfig>>\n },\n receive,\n resync,\n close: () => browserSync?.close(),\n }\n const browserSync = connectBrowserSync(definition, runtime, {\n enabled: () => connection.phase !== 'connected',\n receive(value) {\n if (stashed) {\n // The shown saved config stays put, but `changes` and `dirty` read the new draft.\n stashed = value\n touch()\n } else {\n assignDeep(raw as Record<string, unknown>, value as Record<string, unknown>)\n changed()\n }\n },\n })\n return handle\n }\n\n const handles = new Map(tools.map((definition) => [definition.id, createHandle(definition)]))\n\n // /studio asks its same-origin preview iframe to compare too (see `postCompare`).\n const onMessage = (event: MessageEvent) => {\n const message = event.data as CompareMessage | undefined\n if (event.origin !== location.origin || message?.type !== COMPARE_MESSAGE) return\n handles.get(message.tool)?.compare(message.showSaved)\n }\n if (typeof window !== 'undefined') window.addEventListener?.('message', onMessage)\n\n if (runtime && typeof window !== 'undefined') {\n bridge = connectBridge(runtime, {\n state: (state) => handles.get(state.tool)?.receive(state),\n connection(next) {\n connection = next\n touch()\n if (next.phase === 'connected') for (const handle of handles.values()) void handle.resync()\n },\n })\n }\n\n const studio: Studio = {\n tools: [...handles.values()],\n tool<TConfig extends object>(definition: ToolDefinition<TConfig>) {\n const handle = handles.get(definition.id)\n if (!handle) throw new Error(`[studio] tool \"${definition.id}\" was not passed to createStudio`)\n return handle as unknown as ToolHandle<TConfig>\n },\n get connection() {\n return connection\n },\n get version() {\n return version\n },\n subscribe(listener) {\n studioListeners.add(listener)\n return () => void studioListeners.delete(listener)\n },\n close() {\n bridge?.close()\n for (const handle of handles.values()) handle.close()\n studioListeners.clear()\n if (typeof window !== 'undefined') window.removeEventListener?.('message', onMessage)\n if (instances.get(instanceKey) === studio) instances.delete(instanceKey)\n },\n }\n if (typeof window !== 'undefined') {\n instances.get(instanceKey)?.close()\n instances.set(instanceKey, studio)\n }\n return studio\n}\n\nfunction fail(result: Result<unknown>) {\n if (!result.ok) throw new Error(result.message ?? result.error)\n}\n","export type Change = { path: string[]; before: unknown; after: unknown }\n\n/** Every leaf that differs between two configs of the same shape. Arrays count as leaves. */\nexport function diff(before: unknown, after: unknown, path: string[] = []): Change[] {\n const nested = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n if (nested(before) && nested(after)) {\n const keys = new Set([...Object.keys(before), ...Object.keys(after)])\n return [...keys].flatMap((key) => diff(before[key], after[key], [...path, key]))\n }\n return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ path, before, after }]\n}\n\n/** Dotted paths of every leaf that differs between two configs of the same shape. */\nexport function changedPaths(before: unknown, after: unknown): string[] {\n return diff(before, after).map(({ path }) => path.join('.'))\n}\n","import type { Op, Operations, ServerMessage, ToolState } from '../core/protocol'\nimport { error, type Result } from '../core/result'\n\nexport type Connection = { phase: 'local' } | { phase: 'connecting' } | { phase: 'connected' } | { phase: 'offline' }\n\ntype Pending = (result: Result<unknown>) => void\n\n/** A reconnecting WebSocket to the Studio runtime with request/response correlation. */\nexport function connectBridge(\n url: string,\n handlers: { state: (state: ToolState) => void; connection: (connection: Connection) => void },\n) {\n let socket: WebSocket | null = null\n let nextId = 0\n let retry = 0\n let timer: ReturnType<typeof setTimeout> | undefined\n let closed = false\n const pending = new Map<number, Pending>()\n\n const open = () => {\n handlers.connection({ phase: 'connecting' })\n const ws = new WebSocket(url)\n socket = ws\n ws.onopen = () => {\n retry = 0\n handlers.connection({ phase: 'connected' })\n }\n ws.onmessage = (event) => {\n const message = JSON.parse(String(event.data)) as ServerMessage\n if (message.type === 'state') return handlers.state(message.state)\n pending.get(message.id)?.(message.result)\n pending.delete(message.id)\n }\n ws.onclose = () => {\n socket = null\n for (const resolve of pending.values()) resolve(error('runtime_unavailable'))\n pending.clear()\n if (closed) return\n handlers.connection({ phase: 'offline' })\n timer = setTimeout(open, Math.min(500 * 2 ** retry++, 5000))\n }\n }\n open()\n\n return {\n request<TOp extends Op>(op: TOp, params: Operations[TOp]['params']): Promise<Result<Operations[TOp]['result']>> {\n if (socket?.readyState !== WebSocket.OPEN) return Promise.resolve(error('runtime_unavailable'))\n const id = nextId++\n socket.send(JSON.stringify({ id, op, params }))\n return new Promise((resolve) => pending.set(id, resolve as Pending))\n },\n close() {\n closed = true\n clearTimeout(timer)\n socket?.close()\n },\n }\n}\n\nexport type BridgeClient = ReturnType<typeof connectBridge>\n","import type { ToolDefinition } from '../core/tool'\nimport { validateConfig } from '../core/validate'\n\ntype Snapshot = { type: 'config'; clock: number; sender: string; value: unknown }\n\n/** Ephemeral config sharing between same-origin pages, including Studio's preview iframe. */\nexport function connectBrowserSync<T extends object>(\n definition: ToolDefinition<T>,\n runtime: string | false | undefined,\n handlers: { enabled(): boolean; receive(value: T): void },\n) {\n // Also exclude SSR and Node environments that expose their own BroadcastChannel implementation.\n if (typeof window === 'undefined' || !window.location?.origin || !window.BroadcastChannel) return null\n\n let channel: BroadcastChannel\n try {\n channel = new window.BroadcastChannel(\n JSON.stringify(['joyco-studio:config', runtime || null, definition.id, definition.file]),\n )\n } catch {\n return null\n }\n const sender = crypto.getRandomValues(new Uint32Array(4)).join('-')\n let clock = 0\n let latest: Snapshot | undefined\n let closed = false\n\n channel.onmessage = ({ data }) => {\n if (closed || !data || typeof data !== 'object') return\n if (data.type === 'request') {\n if (latest) channel.postMessage(latest)\n return\n }\n if (\n data.type !== 'config' ||\n !Number.isSafeInteger(data.clock) ||\n data.clock < 1 ||\n typeof data.sender !== 'string'\n )\n return\n clock = Math.max(clock, data.clock)\n if (!handlers.enabled()) return\n // A stable tie-break keeps simultaneous edits and multiple snapshot replies from diverging.\n if (latest && (data.clock < latest.clock || (data.clock === latest.clock && data.sender <= latest.sender))) return\n const result = validateConfig(definition, data.value)\n if (!result.ok) return\n latest = { type: 'config', clock: data.clock, sender: data.sender, value: result.data }\n handlers.receive(result.data)\n }\n // A newly mounted/reloaded preview asks existing peers for their current draft.\n channel.postMessage({ type: 'request' })\n\n return {\n publish(value: T) {\n if (closed) return\n latest = { type: 'config', clock: ++clock, sender, value: structuredClone(value) }\n channel.postMessage(latest)\n },\n close() {\n closed = true\n channel.onmessage = null\n channel.close()\n },\n }\n}\n","import { diff, type Change } from '../core/diff'\n\ntype Entry = { changes: Change[]; key: string; at: number }\n\nconst LIMIT = 100\n/** Edits to the same leaves this close together (a slider drag) undo as one step. */\nconst MERGE_MS = 500\n\n/**\n * Undo/redo for one page's edits. Entries hold only the leaves an edit changed, so stepping back\n * never reverts what another page, the runtime, or an agent changed in between.\n */\nexport function createHistory() {\n const past: Entry[] = []\n let future: Entry[] = []\n /** The newest entry while later edits to the same leaves may still extend it. */\n let open: Entry | null = null\n\n const step = (from: Entry[], to: Entry[], side: 'before' | 'after') => {\n const entry = from.pop()\n if (!entry) return null\n to.push(entry)\n open = null\n return entry.changes.map((change) => ({ path: change.path, value: change[side] }))\n }\n\n return {\n get canUndo() {\n return past.length > 0\n },\n get canRedo() {\n return future.length > 0\n },\n /** Record the edit between two snapshots. `merge: false` keeps it a step of its own (e.g. Discard). */\n record(before: unknown, after: unknown, { merge = true } = {}) {\n // Cloned so later in-place edits (e.g. to an array leaf) cannot rewrite history.\n const changes = structuredClone(diff(before, after))\n if (!changes.length) return\n future = []\n const key = JSON.stringify(changes.map(({ path }) => path))\n const now = Date.now()\n if (merge && open && open.key === key && now - open.at < MERGE_MS) {\n open.changes = open.changes.map((change, i) => ({ ...change, after: changes[i]!.after }))\n open.at = now\n // Dragged back to where it started: nothing left to undo.\n if (open.changes.every((change) => JSON.stringify(change.before) === JSON.stringify(change.after))) {\n past.pop()\n open = null\n }\n return\n }\n const entry = { changes, key, at: now }\n past.push(entry)\n if (past.length > LIMIT) past.shift()\n open = merge ? entry : null\n },\n /** The leaves to write back, or null when there is nothing to undo. */\n undo: () => step(past, future, 'before'),\n redo: () => step(future, past, 'after'),\n }\n}\n\n/** Write `value` at `path` in place; undefined removes the key (it did not exist before the edit). */\nexport function writePath(target: Record<string, unknown>, path: string[], value: unknown) {\n const key = path.at(-1)!\n let parent = target\n for (const segment of path.slice(0, -1)) {\n const next = parent[segment]\n parent = (typeof next === 'object' && next !== null ? next : (parent[segment] = {})) as Record<string, unknown>\n }\n if (value === undefined) delete parent[key]\n else parent[key] = structuredClone(value)\n}\n","/**\n * A deep proxy that reports every write, so plain `property(config, 'key')`\n * bindings from Xyz feed Studio without the tool author wiring callbacks.\n */\nexport function live<T extends object>(target: T, onWrite: () => void): T {\n const proxies = new WeakMap<object, object>()\n const wrap = <O extends object>(object: O): O => {\n let proxy = proxies.get(object)\n if (!proxy) {\n proxy = new Proxy(object, {\n get(target, key, receiver) {\n const value = Reflect.get(target, key, receiver)\n return typeof value === 'object' && value !== null ? wrap(value) : value\n },\n set(target, key, value, receiver) {\n const changed = Reflect.get(target, key, receiver) !== value\n const ok = Reflect.set(target, key, value, receiver)\n if (changed) onWrite()\n return ok\n },\n })\n proxies.set(object, proxy)\n }\n return proxy as O\n }\n return wrap(target)\n}\n\n/** Copy `source` into `target` in place, keeping nested object identities (and every binding to them) intact. */\nexport function assignDeep(target: Record<string, unknown>, source: Record<string, unknown>) {\n for (const [key, value] of Object.entries(source)) {\n const current = target[key]\n const nested = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v)\n if (nested(current) && nested(value))\n assignDeep(current as Record<string, unknown>, value as Record<string, unknown>)\n else target[key] = structuredClone(value)\n }\n}\n"],"mappings":";;;;;;AAAA;AAAA,EACE;AAAA,EAKA;AAAA,OACK;;;ACJA,SAAS,KAAK,QAAiB,OAAgB,OAAiB,CAAC,GAAa;AACnF,QAAM,SAAS,CAAC,UACd,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AACrE,MAAI,OAAO,MAAM,KAAK,OAAO,KAAK,GAAG;AACnC,UAAM,OAAO,oBAAI,IAAI,CAAC,GAAG,OAAO,KAAK,MAAM,GAAG,GAAG,OAAO,KAAK,KAAK,CAAC,CAAC;AACpE,WAAO,CAAC,GAAG,IAAI,EAAE,QAAQ,CAAC,QAAQ,KAAK,OAAO,GAAG,GAAG,MAAM,GAAG,GAAG,CAAC,GAAG,MAAM,GAAG,CAAC,CAAC;AAAA,EACjF;AACA,SAAO,KAAK,UAAU,MAAM,MAAM,KAAK,UAAU,KAAK,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,QAAQ,MAAM,CAAC;AACzF;AAGO,SAAS,aAAa,QAAiB,OAA0B;AACtE,SAAO,KAAK,QAAQ,KAAK,EAAE,IAAI,CAAC,EAAE,KAAK,MAAM,KAAK,KAAK,GAAG,CAAC;AAC7D;;;ACRO,SAAS,cACd,KACA,UACA;AACA,MAAI,SAA2B;AAC/B,MAAI,SAAS;AACb,MAAI,QAAQ;AACZ,MAAI;AACJ,MAAI,SAAS;AACb,QAAM,UAAU,oBAAI,IAAqB;AAEzC,QAAM,OAAO,MAAM;AACjB,aAAS,WAAW,EAAE,OAAO,aAAa,CAAC;AAC3C,UAAM,KAAK,IAAI,UAAU,GAAG;AAC5B,aAAS;AACT,OAAG,SAAS,MAAM;AAChB,cAAQ;AACR,eAAS,WAAW,EAAE,OAAO,YAAY,CAAC;AAAA,IAC5C;AACA,OAAG,YAAY,CAAC,UAAU;AACxB,YAAM,UAAU,KAAK,MAAM,OAAO,MAAM,IAAI,CAAC;AAC7C,UAAI,QAAQ,SAAS,QAAS,QAAO,SAAS,MAAM,QAAQ,KAAK;AACjE,cAAQ,IAAI,QAAQ,EAAE,IAAI,QAAQ,MAAM;AACxC,cAAQ,OAAO,QAAQ,EAAE;AAAA,IAC3B;AACA,OAAG,UAAU,MAAM;AACjB,eAAS;AACT,iBAAW,WAAW,QAAQ,OAAO,EAAG,SAAQ,MAAM,qBAAqB,CAAC;AAC5E,cAAQ,MAAM;AACd,UAAI,OAAQ;AACZ,eAAS,WAAW,EAAE,OAAO,UAAU,CAAC;AACxC,cAAQ,WAAW,MAAM,KAAK,IAAI,MAAM,KAAK,SAAS,GAAI,CAAC;AAAA,IAC7D;AAAA,EACF;AACA,OAAK;AAEL,SAAO;AAAA,IACL,QAAwB,IAAS,QAA+E;AAC9G,UAAI,QAAQ,eAAe,UAAU,KAAM,QAAO,QAAQ,QAAQ,MAAM,qBAAqB,CAAC;AAC9F,YAAM,KAAK;AACX,aAAO,KAAK,KAAK,UAAU,EAAE,IAAI,IAAI,OAAO,CAAC,CAAC;AAC9C,aAAO,IAAI,QAAQ,CAAC,YAAY,QAAQ,IAAI,IAAI,OAAkB,CAAC;AAAA,IACrE;AAAA,IACA,QAAQ;AACN,eAAS;AACT,mBAAa,KAAK;AAClB,cAAQ,MAAM;AAAA,IAChB;AAAA,EACF;AACF;;;ACnDO,SAAS,mBACd,YACA,SACA,UACA;AAEA,MAAI,OAAO,WAAW,eAAe,CAAC,OAAO,UAAU,UAAU,CAAC,OAAO,iBAAkB,QAAO;AAElG,MAAI;AACJ,MAAI;AACF,cAAU,IAAI,OAAO;AAAA,MACnB,KAAK,UAAU,CAAC,uBAAuB,WAAW,MAAM,WAAW,IAAI,WAAW,IAAI,CAAC;AAAA,IACzF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,gBAAgB,IAAI,YAAY,CAAC,CAAC,EAAE,KAAK,GAAG;AAClE,MAAI,QAAQ;AACZ,MAAI;AACJ,MAAI,SAAS;AAEb,UAAQ,YAAY,CAAC,EAAE,KAAK,MAAM;AAChC,QAAI,UAAU,CAAC,QAAQ,OAAO,SAAS,SAAU;AACjD,QAAI,KAAK,SAAS,WAAW;AAC3B,UAAI,OAAQ,SAAQ,YAAY,MAAM;AACtC;AAAA,IACF;AACA,QACE,KAAK,SAAS,YACd,CAAC,OAAO,cAAc,KAAK,KAAK,KAChC,KAAK,QAAQ,KACb,OAAO,KAAK,WAAW;AAEvB;AACF,YAAQ,KAAK,IAAI,OAAO,KAAK,KAAK;AAClC,QAAI,CAAC,SAAS,QAAQ,EAAG;AAEzB,QAAI,WAAW,KAAK,QAAQ,OAAO,SAAU,KAAK,UAAU,OAAO,SAAS,KAAK,UAAU,OAAO,QAAU;AAC5G,UAAM,SAAS,eAAe,YAAY,KAAK,KAAK;AACpD,QAAI,CAAC,OAAO,GAAI;AAChB,aAAS,EAAE,MAAM,UAAU,OAAO,KAAK,OAAO,QAAQ,KAAK,QAAQ,OAAO,OAAO,KAAK;AACtF,aAAS,QAAQ,OAAO,IAAI;AAAA,EAC9B;AAEA,UAAQ,YAAY,EAAE,MAAM,UAAU,CAAC;AAEvC,SAAO;AAAA,IACL,QAAQ,OAAU;AAChB,UAAI,OAAQ;AACZ,eAAS,EAAE,MAAM,UAAU,OAAO,EAAE,OAAO,QAAQ,OAAO,gBAAgB,KAAK,EAAE;AACjF,cAAQ,YAAY,MAAM;AAAA,IAC5B;AAAA,IACA,QAAQ;AACN,eAAS;AACT,cAAQ,YAAY;AACpB,cAAQ,MAAM;AAAA,IAChB;AAAA,EACF;AACF;;;AC5DA,IAAM,QAAQ;AAEd,IAAM,WAAW;AAMV,SAAS,gBAAgB;AAC9B,QAAM,OAAgB,CAAC;AACvB,MAAI,SAAkB,CAAC;AAEvB,MAAI,OAAqB;AAEzB,QAAM,OAAO,CAAC,MAAe,IAAa,SAA6B;AACrE,UAAM,QAAQ,KAAK,IAAI;AACvB,QAAI,CAAC,MAAO,QAAO;AACnB,OAAG,KAAK,KAAK;AACb,WAAO;AACP,WAAO,MAAM,QAAQ,IAAI,CAAC,YAAY,EAAE,MAAM,OAAO,MAAM,OAAO,OAAO,IAAI,EAAE,EAAE;AAAA,EACnF;AAEA,SAAO;AAAA,IACL,IAAI,UAAU;AACZ,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,IAAI,UAAU;AACZ,aAAO,OAAO,SAAS;AAAA,IACzB;AAAA;AAAA,IAEA,OAAO,QAAiB,OAAgB,EAAE,QAAQ,KAAK,IAAI,CAAC,GAAG;AAE7D,YAAM,UAAU,gBAAgB,KAAK,QAAQ,KAAK,CAAC;AACnD,UAAI,CAAC,QAAQ,OAAQ;AACrB,eAAS,CAAC;AACV,YAAM,MAAM,KAAK,UAAU,QAAQ,IAAI,CAAC,EAAE,KAAK,MAAM,IAAI,CAAC;AAC1D,YAAM,MAAM,KAAK,IAAI;AACrB,UAAI,SAAS,QAAQ,KAAK,QAAQ,OAAO,MAAM,KAAK,KAAK,UAAU;AACjE,aAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,QAAQ,OAAO,EAAE,GAAG,QAAQ,OAAO,QAAQ,CAAC,EAAG,MAAM,EAAE;AACxF,aAAK,KAAK;AAEV,YAAI,KAAK,QAAQ,MAAM,CAAC,WAAW,KAAK,UAAU,OAAO,MAAM,MAAM,KAAK,UAAU,OAAO,KAAK,CAAC,GAAG;AAClG,eAAK,IAAI;AACT,iBAAO;AAAA,QACT;AACA;AAAA,MACF;AACA,YAAM,QAAQ,EAAE,SAAS,KAAK,IAAI,IAAI;AACtC,WAAK,KAAK,KAAK;AACf,UAAI,KAAK,SAAS,MAAO,MAAK,MAAM;AACpC,aAAO,QAAQ,QAAQ;AAAA,IACzB;AAAA;AAAA,IAEA,MAAM,MAAM,KAAK,MAAM,QAAQ,QAAQ;AAAA,IACvC,MAAM,MAAM,KAAK,QAAQ,MAAM,OAAO;AAAA,EACxC;AACF;AAGO,SAAS,UAAU,QAAiC,MAAgB,OAAgB;AACzF,QAAM,MAAM,KAAK,GAAG,EAAE;AACtB,MAAI,SAAS;AACb,aAAW,WAAW,KAAK,MAAM,GAAG,EAAE,GAAG;AACvC,UAAM,OAAO,OAAO,OAAO;AAC3B,aAAU,OAAO,SAAS,YAAY,SAAS,OAAO,OAAQ,OAAO,OAAO,IAAI,CAAC;AAAA,EACnF;AACA,MAAI,UAAU,OAAW,QAAO,OAAO,GAAG;AAAA,MACrC,QAAO,GAAG,IAAI,gBAAgB,KAAK;AAC1C;;;ACpEO,SAAS,KAAuB,QAAW,SAAwB;AACxE,QAAM,UAAU,oBAAI,QAAwB;AAC5C,QAAM,OAAO,CAAmB,WAAiB;AAC/C,QAAI,QAAQ,QAAQ,IAAI,MAAM;AAC9B,QAAI,CAAC,OAAO;AACV,cAAQ,IAAI,MAAM,QAAQ;AAAA,QACxB,IAAIA,SAAQ,KAAK,UAAU;AACzB,gBAAM,QAAQ,QAAQ,IAAIA,SAAQ,KAAK,QAAQ;AAC/C,iBAAO,OAAO,UAAU,YAAY,UAAU,OAAO,KAAK,KAAK,IAAI;AAAA,QACrE;AAAA,QACA,IAAIA,SAAQ,KAAK,OAAO,UAAU;AAChC,gBAAM,UAAU,QAAQ,IAAIA,SAAQ,KAAK,QAAQ,MAAM;AACvD,gBAAM,KAAK,QAAQ,IAAIA,SAAQ,KAAK,OAAO,QAAQ;AACnD,cAAI,QAAS,SAAQ;AACrB,iBAAO;AAAA,QACT;AAAA,MACF,CAAC;AACD,cAAQ,IAAI,QAAQ,KAAK;AAAA,IAC3B;AACA,WAAO;AAAA,EACT;AACA,SAAO,KAAK,MAAM;AACpB;AAGO,SAAS,WAAW,QAAiC,QAAiC;AAC3F,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,UAAM,UAAU,OAAO,GAAG;AAC1B,UAAM,SAAS,CAAC,MAAe,OAAO,MAAM,YAAY,MAAM,QAAQ,CAAC,MAAM,QAAQ,CAAC;AACtF,QAAI,OAAO,OAAO,KAAK,OAAO,KAAK;AACjC,iBAAW,SAAoC,KAAgC;AAAA,QAC5E,QAAO,GAAG,IAAI,gBAAgB,KAAK;AAAA,EAC1C;AACF;;;ALuCA,IAAM,kBAAkB;AAIjB,SAAS,YAAY,QAAmC,MAAc,WAAoB;AAC/F,QAAM,UAA0B,EAAE,MAAM,iBAAiB,MAAM,UAAU;AACzE,UAAQ,YAAY,SAAS,SAAS,MAAM;AAC9C;AAGA,SAAS,WAAW,QAAkC;AACpD,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,MAA4B;AACjE,UAAI,MAAM,SAAS,SAAU,QAAO,CAAC,KAAK,KAAK;AAC/C,UAAI,MAAM,SAAS;AACjB,eAAO;AAAA,UACL;AAAA,UACA,EAAE,GAAG,OAAO,YAAY,EAAE,GAAG,MAAM,YAAY,QAAQ,MAAM,WAAW,MAAM,WAAW,OAAO,CAAC,EAAE,EAAE;AAAA,QACvG;AACF,aAAO,CAAC,KAAK,EAAE,GAAG,OAAO,MAAM,KAAK,CAAC;AAAA,IACvC,CAAC;AAAA,EACH;AACF;AAMO,SAAS,0BAA0B,QAA4D;AACpG,SAAO,CAAC,QAAQ,EAAE,OAAO,MAAM;AAC7B,UAAM,UAAU,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,MAAM,MAAM,SAAS,QAAQ;AACtE,UAAM,gBAAgB,CAAC,UAAsB;AAE3C,UAAI,OAAO,aAAc,MAAM,OAAmB,QAAQ,OAAO,EAAG;AACpE,YAAM,SAAS,QAAQ,KAAK,CAAC,EAAE,MAAM,MAAM,MAAM,QAAQ,SAAS,MAAM,MAAc,CAAC;AACvF,UAAI,QAAQ,MAAM,SAAS,SAAU;AACrC,YAAM,QAAQ,OAAO,KAAK;AAAA,QACxB,CAAC,QAAQ,QAAS,SAAiD,GAAG;AAAA,QACtE,aAAa,OAAO,WAAW,SAAS,gBAAgB,OAAO,KAAK,CAAC,CAAC;AAAA,MACxE;AACA,UAAI,OAAO,UAAU,SAAU,QAAO,MAAM,MAAM,KAAK;AAAA,IACzD;AACA,WAAO,QAAQ,iBAAiB,YAAY,aAAa;AACzD,WAAO,MAAM,OAAO,QAAQ,oBAAoB,YAAY,aAAa;AAAA,EAC3E;AACF;AAGO,SAAS,WAAW,SAAqB;AAC9C,MAAI,QAAQ,sBAAsB,SAAS,OAAO;AAChD,YAAQ;AACR,YAAQ,sBAAsB,IAAI;AAAA,EACpC,CAAC;AACD,SAAO,MAAM,qBAAqB,KAAK;AACzC;AAGA,IAAM,YAAmC,WAAuD,kBAC9F,oBAAI,IAAI;AAEH,SAAS,aAAa,EAAE,OAAO,QAAQ,GAA0B;AACtE,QAAM,cAAc,WAAW,KAAK,UAAU,MAAM,IAAI,CAAC,EAAE,IAAI,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,EAAE,KAAK,CAAC;AAC5F,MAAI,SAA8B;AAClC,MAAI,aAAyB,EAAE,OAAO,UAAU,eAAe,QAAQ;AACvE,MAAI,UAAU;AACd,QAAM,kBAAkB,oBAAI,IAAgB;AAC5C,QAAM,QAAQ,MAAM;AAClB;AACA,eAAW,YAAY,gBAAiB,UAAS;AAAA,EACnD;AAEA,QAAM,eAAe,CAAyB,eAAwC;AACpF,UAAM,MAAM,gBAAgB,WAAW,OAAO;AAC9C,UAAM,YAAY,oBAAI,IAA+B;AACrD,QAAI,UAA4B,QAAQ,QAAQ;AAChD,QAAI,YAAY;AAEhB,QAAI,UAA0B;AAC9B,UAAM,UAAU,cAAc;AAE9B,QAAI,WAAW,gBAAgB,GAAG;AAElC,UAAM,UAAU,MAAM;AACpB,iBAAW,gBAAgB,GAAG;AAC9B,aAAO;AACP,iBAAW,YAAY,UAAW,UAAS,OAAO,MAAM;AACxD,YAAM;AAAA,IACR;AACA,UAAM,QAAQ,CAAC,UAAqB;AAClC,aAAO,QAAQ;AACf,YAAM;AAAA,IACR;AACA,UAAM,UAAU,CAAC,UAAqB;AACpC,UAAI,QAAS,WAAU,gBAAgB,MAAM,MAAM,KAAK;AAAA,UACnD,YAAW,KAAgC,MAAM,MAAM,KAAgC;AAC5F,YAAM,KAAK;AACX,UAAI,CAAC,QAAS,SAAQ;AACtB,mBAAa,QAAQ,MAAM,MAAM,KAAgB;AAAA,IACnD;AACA,UAAM,SAAS,YAAY;AACzB,YAAM,SAAS,MAAM,QAAQ,QAAQ,YAAY,EAAE,MAAM,WAAW,GAAG,CAAC;AACxE,UAAI,QAAQ,GAAI,SAAQ,OAAO,IAAI;AAAA,IACrC;AACA,UAAM,OAAO,MAAM;AACjB,UAAI,aAAa,CAAC,OAAQ;AAC1B,kBAAY;AACZ,gBAAU,QAAQ,QAAQ,EAAE,KAAK,YAAY;AAC3C,oBAAY;AACZ,cAAM,SAAS,MAAM,QAAQ,QAAQ,eAAe,EAAE,MAAM,WAAW,IAAI,OAAO,IAAI,CAAC;AACvF,YAAI,CAAC,OAAQ;AACb,YAAI,OAAO,GAAI,OAAM,OAAO,IAAI;AAAA,iBACvB,OAAO,UAAU,uBAAuB;AAC/C,kBAAQ,KAAK,YAAY,WAAW,EAAE,KAAK,OAAO,WAAW,OAAO,KAAK,EAAE;AAC3E,gBAAM,OAAO;AAAA,QACf;AAAA,MACF,CAAC;AAAA,IACH;AAEA,UAAM,SAAS,MAAM;AACnB,cAAQ;AACR,UAAI,CAAC,SAAS;AACZ,qBAAa,QAAQ,GAAG;AACxB,aAAK;AAAA,MACP;AAAA,IACF;AACA,UAAM,UAAU,CAAC,UAA2C;AAC1D,UAAI,CAAC,MAAO,QAAO;AACnB,iBAAW,EAAE,MAAM,MAAM,KAAK,MAAO,WAAU,KAAgC,MAAM,KAAK;AAC1F,aAAO;AACP,aAAO;AAAA,IACT;AAEA,UAAM,SAAS;AAAA,MACb;AAAA,MACA,QAAQ,KAAK,KAAK,MAAM;AACtB,YAAI,CAAC,QAAS,SAAQ,OAAO,UAAU,GAAG;AAC1C,eAAO;AAAA,MACT,CAAC;AAAA,MACD,OAAO;AAAA,MACP,SAAS;AAAA,MACT,IAAI,QAAiB;AACnB,eAAO,OAAO,OAAO,MAAM,SAAS,WAAW;AAAA,MACjD;AAAA,MACA,IAAI,UAAU;AACZ,eAAO,aAAa,OAAO,OAAO,WAAW,GAAG;AAAA,MAClD;AAAA,MACA,IAAI,QAAQ;AACV,eAAO,OAAO,QAAQ,SAAS;AAAA,MACjC;AAAA,MACA,WAAW;AAAA,MACX,QAAQ,WAAoB;AAC1B,YAAI,cAAc,OAAO,UAAW;AACpC,YAAI,UAAW,WAAU,gBAAgB,GAAG;AAC5C,cAAM,OAAO,YAAY,OAAO,QAAQ;AACxC,mBAAW,KAAgC,IAA+B;AAC1E,YAAI,CAAC,UAAW,WAAU;AAC1B,eAAO,YAAY;AACnB,gBAAQ;AAAA,MACV;AAAA,MACA,IAAI,UAAU;AACZ,eAAO,QAAQ;AAAA,MACjB;AAAA,MACA,IAAI,UAAU;AACZ,eAAO,QAAQ;AAAA,MACjB;AAAA,MACA,MAAM,MAAM,CAAC,WAAW,QAAQ,QAAQ,KAAK,CAAC;AAAA,MAC9C,MAAM,MAAM,CAAC,WAAW,QAAQ,QAAQ,KAAK,CAAC;AAAA,MAC9C,UAAU,UAAqC;AAC7C,kBAAU,IAAI,QAAQ;AACtB,eAAO,MAAM,KAAK,UAAU,OAAO,QAAQ;AAAA,MAC7C;AAAA,MACA,UAAU,MAAM,WAAW,SAAS,OAAO,MAAM;AAAA,MACjD,QAAqB;AACnB,cAAM,UAAuB,UACzB;AAAA,UACE,MAAM,OAAO,QAAQ,MAAM,OAAO,KAAK,EAAE,KAAK,IAAI,GAAG,cAAS;AAAA,UAC9D,SAAS,OAAO,WAAW,MAAM,OAAO,QAAQ,EAAE,KAAK,IAAI,GAAG,WAAW;AAAA,QAC3E,IACA,CAAC;AACL,eAAO;AAAA,UACL,OAAO,WAAW,SAAS,WAAW;AAAA,UACtC,QAAQ,OAAO,EAAE,GAAG,WAAW,OAAO,SAAS,CAAC,GAAG,GAAG,QAAQ;AAAA,UAC9D,QAAQ,0BAA0B,MAAM;AAAA,QAC1C;AAAA,MACF;AAAA,MACA,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,CAAC,GAAG;AACjC,YAAI,CAAC,OAAQ,QAAO,MAAM,qBAAqB;AAC/C,cAAM;AACN,cAAM,WAAW,OAAO,OAAO,MAAM;AACrC,YAAI,aAAa,OAAW,QAAO,MAAM,qBAAqB;AAC9D,cAAM,SAAS,MAAM,OAAO,QAAQ,aAAa,EAAE,MAAM,WAAW,IAAI,UAAU,MAAM,CAAC;AACzF,YAAI,OAAO,GAAI,OAAM,OAAO,IAAI;AAChC,eAAO;AAAA,MACT;AAAA,MACA,MAAM,UAAU;AACd,YAAI,CAAC,OAAQ,QAAO,MAAM,qBAAqB;AAC/C,cAAM;AACN,cAAM,SAAS;AACf,cAAM,SAAS,MAAM,OAAO,QAAQ,gBAAgB,EAAE,MAAM,WAAW,GAAG,CAAC;AAC3E,YAAI,OAAO,IAAI;AACb,kBAAQ,OAAO,IAAI;AAEnB,cAAI,CAAC,QAAS,SAAQ,OAAO,QAAQ,KAAK,EAAE,OAAO,MAAM,CAAC;AAAA,QAC5D;AACA,eAAO;AAAA,MACT;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,MAAM,aAAa,MAAM;AAAA,IAClC;AACA,UAAM,cAAc,mBAAmB,YAAY,SAAS;AAAA,MAC1D,SAAS,MAAM,WAAW,UAAU;AAAA,MACpC,QAAQ,OAAO;AACb,YAAI,SAAS;AAEX,oBAAU;AACV,gBAAM;AAAA,QACR,OAAO;AACL,qBAAW,KAAgC,KAAgC;AAC3E,kBAAQ;AAAA,QACV;AAAA,MACF;AAAA,IACF,CAAC;AACD,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,IAAI,MAAM,IAAI,CAAC,eAAe,CAAC,WAAW,IAAI,aAAa,UAAU,CAAC,CAAC,CAAC;AAG5F,QAAM,YAAY,CAAC,UAAwB;AACzC,UAAM,UAAU,MAAM;AACtB,QAAI,MAAM,WAAW,SAAS,UAAU,SAAS,SAAS,gBAAiB;AAC3E,YAAQ,IAAI,QAAQ,IAAI,GAAG,QAAQ,QAAQ,SAAS;AAAA,EACtD;AACA,MAAI,OAAO,WAAW,YAAa,QAAO,mBAAmB,WAAW,SAAS;AAEjF,MAAI,WAAW,OAAO,WAAW,aAAa;AAC5C,aAAS,cAAc,SAAS;AAAA,MAC9B,OAAO,CAAC,UAAU,QAAQ,IAAI,MAAM,IAAI,GAAG,QAAQ,KAAK;AAAA,MACxD,WAAW,MAAM;AACf,qBAAa;AACb,cAAM;AACN,YAAI,KAAK,UAAU,YAAa,YAAW,UAAU,QAAQ,OAAO,EAAG,MAAK,OAAO,OAAO;AAAA,MAC5F;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,SAAiB;AAAA,IACrB,OAAO,CAAC,GAAG,QAAQ,OAAO,CAAC;AAAA,IAC3B,KAA6B,YAAqC;AAChE,YAAM,SAAS,QAAQ,IAAI,WAAW,EAAE;AACxC,UAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,kBAAkB,WAAW,EAAE,kCAAkC;AAC9F,aAAO;AAAA,IACT;AAAA,IACA,IAAI,aAAa;AACf,aAAO;AAAA,IACT;AAAA,IACA,IAAI,UAAU;AACZ,aAAO;AAAA,IACT;AAAA,IACA,UAAU,UAAU;AAClB,sBAAgB,IAAI,QAAQ;AAC5B,aAAO,MAAM,KAAK,gBAAgB,OAAO,QAAQ;AAAA,IACnD;AAAA,IACA,QAAQ;AACN,cAAQ,MAAM;AACd,iBAAW,UAAU,QAAQ,OAAO,EAAG,QAAO,MAAM;AACpD,sBAAgB,MAAM;AACtB,UAAI,OAAO,WAAW,YAAa,QAAO,sBAAsB,WAAW,SAAS;AACpF,UAAI,UAAU,IAAI,WAAW,MAAM,OAAQ,WAAU,OAAO,WAAW;AAAA,IACzE;AAAA,EACF;AACA,MAAI,OAAO,WAAW,aAAa;AACjC,cAAU,IAAI,WAAW,GAAG,MAAM;AAClC,cAAU,IAAI,aAAa,MAAM;AAAA,EACnC;AACA,SAAO;AACT;AAEA,SAAS,KAAK,QAAyB;AACrC,MAAI,CAAC,OAAO,GAAI,OAAM,IAAI,MAAM,OAAO,WAAW,OAAO,KAAK;AAChE;","names":["target"]}
package/dist/index.js CHANGED
@@ -1,10 +1,8 @@
1
- import {
2
- validateConfig
3
- } from "./chunk-VYNEG5Z7.js";
4
1
  import {
5
2
  error,
6
- success
7
- } from "./chunk-QKEAOM2U.js";
3
+ success,
4
+ validateConfig
5
+ } from "./chunk-X4YLM5HA.js";
8
6
 
9
7
  // src/core/tool.ts
10
8
  var ID = /^[a-z0-9][a-z0-9-]*$/;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/core/tool.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\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> = {\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 /** 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 /** 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\nexport type AnyTool = ToolDefinition<any>\n\nexport type ConfigOf<TTool> = TTool extends ToolDefinition<infer TConfig> ? TConfig : never\n\nconst ID = /^[a-z0-9][a-z0-9-]*$/\n\nexport function defineTool<TConfig extends object>(tool: ToolDefinition<TConfig>): ToolDefinition<TConfig> {\n if (!ID.test(tool.id)) throw new Error(`[studio] tool id \"${tool.id}\" must be kebab-case`)\n return tool\n}\n\nexport type StudioConfig = {\n tools: readonly AnyTool[]\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(config: StudioConfig): StudioConfig {\n return config\n}\n"],"mappings":";;;;;;;;;AAuCA,IAAM,KAAK;AAEJ,SAAS,WAAmC,MAAwD;AACzG,MAAI,CAAC,GAAG,KAAK,KAAK,EAAE,EAAG,OAAM,IAAI,MAAM,qBAAqB,KAAK,EAAE,sBAAsB;AACzF,SAAO;AACT;AAYO,IAAM,eAAe;AAErB,SAAS,aAAa,QAAoC;AAC/D,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/core/tool.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\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> = {\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 /** 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 /** 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\nexport type AnyTool = ToolDefinition<any>\n\nexport type ConfigOf<TTool> = TTool extends ToolDefinition<infer TConfig> ? TConfig : never\n\nconst ID = /^[a-z0-9][a-z0-9-]*$/\n\nexport function defineTool<TConfig extends object>(tool: ToolDefinition<TConfig>): ToolDefinition<TConfig> {\n if (!ID.test(tool.id)) throw new Error(`[studio] tool id \"${tool.id}\" must be kebab-case`)\n return tool\n}\n\nexport type StudioConfig = {\n tools: readonly AnyTool[]\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(config: StudioConfig): StudioConfig {\n return config\n}\n"],"mappings":";;;;;;;AAuCA,IAAM,KAAK;AAEJ,SAAS,WAAmC,MAAwD;AACzG,MAAI,CAAC,GAAG,KAAK,KAAK,EAAE,EAAG,OAAM,IAAI,MAAM,qBAAqB,KAAK,EAAE,sBAAsB;AACzF,SAAO;AACT;AAYO,IAAM,eAAe;AAErB,SAAS,aAAa,QAAoC;AAC/D,SAAO;AACT;","names":[]}
package/dist/react.d.ts CHANGED
@@ -77,7 +77,7 @@ type Connection = {
77
77
 
78
78
  type ToolHandle<TConfig extends object = any> = {
79
79
  readonly definition: ToolDefinition<TConfig>;
80
- /** The live config. Read it to apply; writes (from any pane, or your code) sync to the runtime. */
80
+ /** The live config. Writes sync to same-origin pages and, when connected, the runtime. */
81
81
  readonly config: TConfig;
82
82
  /** Runtime-owned state; null until the runtime answers (always null without one). */
83
83
  readonly state: ToolState<TConfig> | null;
@@ -89,13 +89,28 @@ type ToolHandle<TConfig extends object = any> = {
89
89
  controls(): DebugSchema;
90
90
  /** Xyz `Debuggable`: `debugManager.register(handle)` mounts this tool in the app's floating pane. */
91
91
  debug(): DebugFolder;
92
+ /** The config on disk: the runtime's saved value or, until one answers, the bundled `initial`. */
93
+ readonly saved: TConfig;
94
+ /** Dotted paths where the draft differs from `saved`. Tracked locally, so it works without a runtime too. */
95
+ readonly changes: string[];
96
+ /** The draft differs from `saved`. */
97
+ readonly dirty: boolean;
92
98
  /** True while `compare(true)` shows the saved config in place of the draft. */
93
99
  readonly comparing: boolean;
94
100
  /**
95
- * Temporarily show the saved config (true) or go back to the draft (false). Local only: nothing
101
+ * Temporarily show `saved` (true) or go back to the draft (false). Local only: nothing
96
102
  * reaches the runtime, and edits made while comparing are dropped when it ends.
97
103
  */
98
104
  compare(showSaved: boolean): void;
105
+ /** True when `undo()` has an edit made on this page to step back through. */
106
+ readonly canUndo: boolean;
107
+ readonly canRedo: boolean;
108
+ /**
109
+ * Step back through edits made on this page (a slider drag counts as one), then sync the result like any
110
+ * other edit. Only the leaves each edit changed are restored. Returns false with nothing to undo or while comparing.
111
+ */
112
+ undo(): boolean;
113
+ redo(): boolean;
99
114
  save(options?: {
100
115
  force?: boolean;
101
116
  }): Promise<Result<ToolState<TConfig>>>;