@joycostudio/studio 0.1.6 → 0.2.1

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/cli.js CHANGED
@@ -14,6 +14,29 @@ var DEFAULT_PORT = 5179;
14
14
  import { watch } from "fs";
15
15
  import path2 from "path";
16
16
 
17
+ // src/core/diff.ts
18
+ function diff(before, after, path9 = []) {
19
+ const nested = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20
+ if (nested(before) && nested(after)) {
21
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
22
+ return [...keys].flatMap((key) => diff(before[key], after[key], [...path9, key]));
23
+ }
24
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ path: path9, before, after }];
25
+ }
26
+ var define = (target, key, value) => Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
27
+ function writePath(target, path9, value) {
28
+ const key = path9.at(-1);
29
+ let parent = target;
30
+ for (const segment of path9.slice(0, -1)) {
31
+ const next = Object.hasOwn(parent, segment) ? parent[segment] : void 0;
32
+ if (typeof next !== "object" || next === null) define(parent, segment, {});
33
+ parent = parent[segment];
34
+ }
35
+ if (value === void 0) {
36
+ if (Object.hasOwn(parent, key)) delete parent[key];
37
+ } else define(parent, key, structuredClone(value));
38
+ }
39
+
17
40
  // src/core/result.ts
18
41
  var success = (data) => ({ ok: true, data });
19
42
  var error = (code, message) => ({
@@ -50,9 +73,9 @@ function shapeMismatch(reference, value, path9) {
50
73
  if (expected === "object") {
51
74
  const ref = reference;
52
75
  const obj = value;
53
- for (const key of Object.keys(obj)) if (!(key in ref)) return `${path9}.${key}: unknown key`;
76
+ for (const key of Object.keys(obj)) if (!Object.hasOwn(ref, key)) return `${path9}.${key}: unknown key`;
54
77
  for (const key of Object.keys(ref)) {
55
- if (!(key in obj)) return `${path9}.${key}: missing`;
78
+ if (!Object.hasOwn(obj, key)) return `${path9}.${key}: missing`;
56
79
  const nested = shapeMismatch(ref[key], obj[key], `${path9}.${key}`);
57
80
  if (nested) return nested;
58
81
  }
@@ -143,6 +166,28 @@ var equal = (a, b) => {
143
166
  const keys = Object.keys(a);
144
167
  return keys.length === Object.keys(b).length && keys.every((key) => equal(a[key], b[key]));
145
168
  };
169
+ function rebase(tool2, old, saved) {
170
+ const changes = diff(old.saved.value, old.draft.value);
171
+ const apply = (base, edits) => {
172
+ const candidate = structuredClone(base);
173
+ try {
174
+ for (const { path: path9, after } of edits) writePath(candidate, path9, after);
175
+ } catch (cause) {
176
+ return error("invalid_config", cause instanceof Error ? cause.message : String(cause));
177
+ }
178
+ return validateConfig(tool2, candidate);
179
+ };
180
+ const all = apply(saved, changes);
181
+ if (all.ok) return { value: all.data, dropped: [] };
182
+ let value = saved;
183
+ const dropped = [];
184
+ for (const change of changes) {
185
+ const parsed = apply(value, [change]);
186
+ if (parsed.ok) value = parsed.data;
187
+ else dropped.push(change.path.join("."));
188
+ }
189
+ return { value, dropped };
190
+ }
146
191
  async function load(tool2, file) {
147
192
  const disk = await readDisk(file);
148
193
  if (!disk) return { value: structuredClone(tool2.initial), hash: null };
@@ -302,24 +347,22 @@ async function createRuntime({ root, tools }) {
302
347
  const prepared = await prepare(root, nextTools);
303
348
  for (const [id, old] of entries) {
304
349
  const next = prepared.entries.get(id);
305
- if ((!next || next.file !== old.file) && old.state.dirty)
306
- throw new Error(`Save or discard "${id}" before removing it or moving its config.`);
307
- if (!next || next.file !== old.file) continue;
308
- const parsed = validateConfig(next.tool, old.state.draft.value);
309
- if (!parsed.ok && old.state.dirty)
310
- throw new Error(
311
- `Draft for "${id}" is incompatible with the new controls; save/discard it first: ${parsed.message}`
312
- );
313
- if (parsed.ok && old.state.dirty) {
314
- next.state = {
315
- ...old.state,
316
- saved: next.state.saved,
317
- conflict: old.state.conflict || next.state.saved.hash !== old.state.saved.hash,
318
- draft: { value: parsed.data, revision: old.state.draft.revision + 1 }
319
- };
320
- } else {
321
- next.state.draft.revision = old.state.draft.revision + 1;
350
+ if (!next || next.file !== old.file) {
351
+ if (old.state.dirty)
352
+ console.warn(`[studio] dropped unsaved "${id}" edits: the tool was removed or its config moved`);
353
+ continue;
322
354
  }
355
+ next.state.draft.revision = old.state.draft.revision + 1;
356
+ if (!old.state.dirty) continue;
357
+ const { value, dropped } = rebase(next.tool, old.state, next.state.saved.value);
358
+ if (dropped.length)
359
+ console.warn(`[studio] dropped unsaved "${id}" edits that no longer fit its controls: ${dropped.join(", ")}`);
360
+ next.state = {
361
+ ...old.state,
362
+ saved: next.state.saved,
363
+ conflict: old.state.conflict || next.state.saved.hash !== old.state.saved.hash,
364
+ draft: { value, revision: next.state.draft.revision }
365
+ };
323
366
  }
324
367
  entries = prepared.entries;
325
368
  ids = prepared.ids;
@@ -970,7 +1013,7 @@ import { parseArgs } from "util";
970
1013
  // package.json
971
1014
  var package_default = {
972
1015
  name: "@joycostudio/studio",
973
- version: "0.1.6",
1016
+ version: "0.2.1",
974
1017
  description: "Plug project-specific tools into a /studio route, a floating debug pane, and MCP agents.",
975
1018
  type: "module",
976
1019
  license: "ISC",
@@ -1017,6 +1060,10 @@ var package_default = {
1017
1060
  types: "./dist/controls.d.ts",
1018
1061
  default: "./dist/controls.js"
1019
1062
  },
1063
+ "./ui": {
1064
+ types: "./dist/ui.d.ts",
1065
+ default: "./dist/ui.js"
1066
+ },
1020
1067
  "./next/config": {
1021
1068
  types: "./dist/next-config.d.ts",
1022
1069
  default: "./dist/next-config.js"
@@ -1026,7 +1073,7 @@ var package_default = {
1026
1073
  build: "pnpm build:shadow-css && tsup && tailwindcss -i src/react/styles.css -o dist/styles.css --minify && cp src/react/command-menu-export.css dist/command-menu.css",
1027
1074
  dev: 'pnpm build:shadow-css && pnpm run --parallel "/^dev:/"',
1028
1075
  test: "vitest run",
1029
- "test:browser": "STUDIO_TEST_BROWSER=1 vitest run test/browser-sync.test.ts",
1076
+ "test:browser": "STUDIO_TEST_BROWSER=1 vitest run test/browser-sync.test.ts test/ui.test.ts",
1030
1077
  "test:capture": "STUDIO_TEST_CAPTURE=1 vitest run test/capture.test.ts",
1031
1078
  typecheck: "tsc --noEmit",
1032
1079
  "dev:js": "tsup --watch",
@@ -1053,7 +1100,8 @@ var package_default = {
1053
1100
  typescript: "^5.9.3"
1054
1101
  },
1055
1102
  peerDependencies: {
1056
- "@joycostudio/xyz": "^1.4.0",
1103
+ "@joycostudio/xyz": "^1.5.0",
1104
+ dialkit: "^2.2.0",
1057
1105
  react: ">=18",
1058
1106
  "react-dom": ">=18",
1059
1107
  tweakpane: "^4.0.5"
@@ -1067,10 +1115,13 @@ var package_default = {
1067
1115
  },
1068
1116
  "react-dom": {
1069
1117
  optional: true
1118
+ },
1119
+ dialkit: {
1120
+ optional: true
1070
1121
  }
1071
1122
  },
1072
1123
  devDependencies: {
1073
- "@joycostudio/xyz": "^1.4.0",
1124
+ "@joycostudio/xyz": "^1.5.0",
1074
1125
  "@modelcontextprotocol/client": "^2.3.1",
1075
1126
  "@tailwindcss/cli": "^4.3.3",
1076
1127
  "@tweakpane/core": "^2.0.5",
@@ -1078,6 +1129,7 @@ var package_default = {
1078
1129
  "@types/react": "^19.3.0",
1079
1130
  "@types/react-dom": "^19.3.0",
1080
1131
  "@types/ws": "^8.18.2",
1132
+ dialkit: "^2.2.0",
1081
1133
  react: "^19.3.0",
1082
1134
  "react-dom": "^19.3.0",
1083
1135
  tailwindcss: "^4.3.3",
@@ -1138,9 +1190,11 @@ async function init(argv) {
1138
1190
  const { positionals, values } = parseArgs({
1139
1191
  args: argv,
1140
1192
  allowPositionals: true,
1141
- options: { "no-install": { type: "boolean" } }
1193
+ options: { "no-install": { type: "boolean" }, ui: { type: "string", default: "tweakpane" } }
1142
1194
  });
1143
- if (positionals.length > 1) throw new Error("Usage: studio init [directory] [--no-install]");
1195
+ if (positionals.length > 1) throw new Error("Usage: studio init [directory] [--ui tweakpane|dialkit] [--no-install]");
1196
+ const ui = values.ui;
1197
+ if (ui !== "tweakpane" && ui !== "dialkit") throw new Error(`[studio] --ui must be tweakpane or dialkit, got ${ui}.`);
1144
1198
  const root = path6.resolve(positionals[0] ?? ".");
1145
1199
  const manifest = path6.join(root, "package.json");
1146
1200
  const pkg = JSON.parse(readFileSync3(manifest, "utf8"));
@@ -1161,10 +1215,17 @@ async function init(argv) {
1161
1215
  const page = `${app}/studio/page.tsx`;
1162
1216
  const preview = `${app}/studio-preview/page.tsx`;
1163
1217
  const files = {
1164
- [client]: `import { createStudio } from '@joycostudio/studio/next'
1218
+ [client]: ui === "dialkit" ? `import 'dialkit/vanilla/styles.css'
1219
+ import { createStudio } from '@joycostudio/studio/next'
1220
+ import { dialkit } from '@joycostudio/studio/ui'
1221
+ import { tools } from '${relative(client, ".studio/registry.generated")}'
1222
+
1223
+ export const studio = createStudio({ tools, ui: dialkit(() => import('dialkit/vanilla')) })
1224
+ ` : `import { createStudio } from '@joycostudio/studio/next'
1225
+ import { tweakpane } from '@joycostudio/studio/ui'
1165
1226
  import { tools } from '${relative(client, ".studio/registry.generated")}'
1166
1227
 
1167
- export const studio = createStudio({ tools })
1228
+ export const studio = createStudio({ tools, ui: tweakpane(() => import('tweakpane')) })
1168
1229
  `,
1169
1230
  [data]: '{ "scale": 1 }\n',
1170
1231
  [tool2]: `import { defineTool } from '@joycostudio/studio'
@@ -1251,7 +1312,8 @@ export default function StudioPreview() {
1251
1312
  if (pkg.dependencies?.["@joycostudio/studio"] || pkg.devDependencies?.["@joycostudio/studio"]) {
1252
1313
  try {
1253
1314
  const require2 = createRequire(manifest);
1254
- for (const entry of ["next", "next/config", "controls", "react"]) require2.resolve(`@joycostudio/studio/${entry}`);
1315
+ for (const entry of ["next", "next/config", "controls", "react", "ui"])
1316
+ require2.resolve(`@joycostudio/studio/${entry}`);
1255
1317
  } catch {
1256
1318
  throw new Error(
1257
1319
  "[studio] The declared Studio dependency is missing or lacks the required exports. Install the same version (or preview) as this initializer, then rerun init. No files were changed."
@@ -1261,7 +1323,7 @@ export default function StudioPreview() {
1261
1323
  const dependencies = {
1262
1324
  "@joycostudio/studio": `^${package_default.version}`,
1263
1325
  "@joycostudio/xyz": package_default.peerDependencies["@joycostudio/xyz"],
1264
- tweakpane: package_default.peerDependencies.tweakpane
1326
+ [ui]: package_default.peerDependencies[ui]
1265
1327
  };
1266
1328
  pkg.dependencies ??= {};
1267
1329
  for (const [name, version] of Object.entries(dependencies)) {
@@ -1436,7 +1498,7 @@ function watchDiscovery(initial, refresh) {
1436
1498
  var HELP = `Usage: studio dev [--config studio.config.ts] [--port ${DEFAULT_PORT}] [--app-url http://localhost:3000] [-- <command...>]
1437
1499
  studio generate [--config studio.config.ts]
1438
1500
  studio install-browser
1439
- studio init [directory] [--no-install]
1501
+ studio init [directory] [--ui tweakpane|dialkit] [--no-install]
1440
1502
 
1441
1503
  Starts the Studio runtime with a WebSocket bridge and an HTTP MCP endpoint at /mcp,
1442
1504
  moving to the next free port when another project's Studio already uses it.
@@ -1499,27 +1561,20 @@ async function main(argv) {
1499
1561
  `[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${path8.relative(process.cwd(), root) || "."}`
1500
1562
  );
1501
1563
  console.log(`[studio] MCP ready on http://127.0.0.1:${bridge.port}/mcp`);
1502
- let rejected = false;
1503
1564
  let accepted = discovery;
1504
1565
  const watcher = discovery && watchDiscovery(discovery, async () => {
1566
+ const next = await loadConfig(process.cwd(), values.config, false, accepted);
1505
1567
  try {
1506
- const next = await loadConfig(process.cwd(), values.config, false, accepted);
1507
1568
  await runtime.replaceTools(next.config.tools);
1508
- await next.discovery?.write();
1509
- accepted = next.discovery;
1510
- rejected = false;
1511
- return next.discovery;
1512
1569
  } catch (cause) {
1513
- rejected = true;
1514
- throw cause;
1570
+ throw new DiscoveryInputError(cause, next.discovery?.dependencies ?? /* @__PURE__ */ new Set());
1515
1571
  }
1516
- });
1517
- const unsubscribe = runtime.subscribe((state) => {
1518
- if (rejected && !state.dirty) watcher?.retry();
1572
+ await next.discovery?.write();
1573
+ accepted = next.discovery;
1574
+ return next.discovery;
1519
1575
  });
1520
1576
  const shutdown = async (code = 0) => {
1521
1577
  watcher?.close();
1522
- unsubscribe();
1523
1578
  runtime.close();
1524
1579
  await bridge.close();
1525
1580
  process.exit(code);