@pinepaper.studio/mcp-server 1.6.9 → 1.6.11

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.
Files changed (46) hide show
  1. package/README.md +155 -4
  2. package/dist/agent/export-manager.d.ts.map +1 -1
  3. package/dist/agent/export-manager.js +5 -3
  4. package/dist/agent/export-manager.js.map +1 -1
  5. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  6. package/dist/browser/puppeteer-controller.js +128 -3
  7. package/dist/browser/puppeteer-controller.js.map +1 -1
  8. package/dist/design/design-systems.d.ts +12 -3
  9. package/dist/design/design-systems.d.ts.map +1 -1
  10. package/dist/design/design-systems.js +12 -11
  11. package/dist/design/design-systems.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +9 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/tools/definitions.d.ts +1 -1
  16. package/dist/tools/definitions.d.ts.map +1 -1
  17. package/dist/tools/definitions.js +80 -10
  18. package/dist/tools/definitions.js.map +1 -1
  19. package/dist/tools/engine-methods.d.ts.map +1 -1
  20. package/dist/tools/engine-methods.js +4 -0
  21. package/dist/tools/engine-methods.js.map +1 -1
  22. package/dist/tools/engine-surface.d.ts +30 -0
  23. package/dist/tools/engine-surface.d.ts.map +1 -0
  24. package/dist/tools/engine-surface.js +1192 -0
  25. package/dist/tools/engine-surface.js.map +1 -0
  26. package/dist/tools/handlers/font.d.ts.map +1 -1
  27. package/dist/tools/handlers/font.js +77 -10
  28. package/dist/tools/handlers/font.js.map +1 -1
  29. package/dist/tools/handlers.d.ts +16 -0
  30. package/dist/tools/handlers.d.ts.map +1 -1
  31. package/dist/tools/handlers.js +126 -4
  32. package/dist/tools/handlers.js.map +1 -1
  33. package/dist/types/code-generator.d.ts +15 -0
  34. package/dist/types/code-generator.d.ts.map +1 -1
  35. package/dist/types/code-generator.js +499 -105
  36. package/dist/types/code-generator.js.map +1 -1
  37. package/dist/types/schemas.d.ts +44 -39
  38. package/dist/types/schemas.d.ts.map +1 -1
  39. package/dist/types/schemas.js +19 -3
  40. package/dist/types/schemas.js.map +1 -1
  41. package/dist/vendor/design/design-style.d.ts +3 -1
  42. package/dist/vendor/design/design-style.d.ts.map +1 -1
  43. package/dist/vendor/design/design-style.js +37 -2
  44. package/dist/vendor/design/design-style.js.map +1 -1
  45. package/manifest.json +1 -1
  46. package/package.json +5 -2
@@ -181,12 +181,27 @@ app.historyManager.saveState();
181
181
  */
182
182
  function generateModifyItemCode(itemId, properties) {
183
183
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
184
+ // modifyItem(id, changes), NOT select() + modify().
185
+ //
186
+ // modify() operates on the SELECTION, so this selected an id and then edited
187
+ // whatever was selected — on an id that does not resolve, select changes
188
+ // nothing and modify edits the PREVIOUS selection instead. modifyItem takes
189
+ // the reference and returns false when it cannot resolve it, which is the
190
+ // answer this emitter needs and never asked for.
191
+ //
192
+ // The warning the engine logs on that path is console.warn, which production
193
+ // strips, so the failure was invisible twice over.
184
194
  let code = `
185
195
  // Modify item ${itemId}
186
- app.select('${itemId}');`;
187
- // Pass non-dynamic-content properties to app.modify
196
+ let _modified = true;`;
188
197
  if (Object.keys(restProperties).length > 0) {
189
- code += `\napp.modify(${JSON.stringify(restProperties, null, 2)});`;
198
+ code += `
199
+ _modified = app.modifyItem('${itemId}', ${JSON.stringify(restProperties, null, 2)}) !== false;
200
+ if (!_modified) { return { success: false, error: 'no item ${itemId} — nothing was modified' }; }`;
201
+ }
202
+ else {
203
+ code += `
204
+ if (!app.getItemById('${itemId}')) { return { success: false, error: 'no item ${itemId}' }; }`;
190
205
  }
191
206
  // Handle dynamic content type changes
192
207
  if (contentType !== undefined) {
@@ -212,10 +227,10 @@ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.it
212
227
  }
213
228
  code += `
214
229
  app.historyManager.saveState();
215
-
216
- // Return success
217
- ({ success: true, itemId: '${itemId}' });`;
218
- return code.trim();
230
+ return { success: true, itemId: '${itemId}' };`;
231
+ // Wrapped, because the body now RETURNS early on a miss rather than falling
232
+ // through to an unconditional success.
233
+ return `(function() {\n${code.trim()}\n})();`;
219
234
  }
220
235
  /**
221
236
  * Template for deleting items
@@ -675,9 +690,24 @@ function generateSetCanvasSizeCode(width, height, preset) {
675
690
  : `{ width: ${width}, height: ${height} }`;
676
691
  return `
677
692
  // Set canvas size
678
- app.setCanvasSize(${sizeArg});
679
- app.historyManager.saveState();
680
- ({ success: true, width: ${width}, height: ${height} });
693
+ (function() {
694
+ // READ THE VERDICT. This called setCanvasSize and then declared success with
695
+ // the dimensions it had ASKED for — so an unknown preset, or a width of -5,
696
+ // came back as a successful resize while the canvas was left exactly as it
697
+ // was. The engine now refuses by name; older builds return undefined, which
698
+ // is not a refusal and must not be read as one.
699
+ const r = app.setCanvasSize(${sizeArg});
700
+ if (r && r.ok === false) {
701
+ return { success: false, error: r.reason || 'the canvas size was refused',
702
+ ...(r.known ? { knownPresets: r.known } : {}) };
703
+ }
704
+ app.historyManager.saveState();
705
+ // Report what the canvas ACTUALLY took where the engine says so, rather than
706
+ // echoing the request back.
707
+ return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
708
+ ...(r && r.preset ? { preset: r.preset } : {}),
709
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {}) };
710
+ })();
681
711
  `.trim();
682
712
  }
683
713
  /**
@@ -886,19 +916,36 @@ function generateBatchModifyCode(modifications) {
886
916
  return `
887
917
  // Batch modify ${modifications.length} items
888
918
  const modifications = ${modsJson};
919
+ // TWO BUGS, ONE CALL, AND BOTH FAILED LOUDLY IN THE WRONG PLACE.
920
+ //
921
+ // 1. The engine takes { item, params } with a LIVE ITEM, or { itemId, params }
922
+ // with a registry id. This put the id STRING into the item field, which is
923
+ // truthy — so the engine's own id lookup never ran, the string was treated
924
+ // as an item, and Object.assign got a string: "Cannot convert undefined or
925
+ // null to object", from a call that named the id correctly.
926
+ // 2. batchModify returns a COUNT, not an array. Calling .map on a number
927
+ // is the second reported error, and it fires even when the modify worked.
889
928
  const results = app.batchModify(modifications.map(mod => ({
890
- item: mod.itemId,
929
+ itemId: mod.itemId,
891
930
  params: mod.params
892
931
  })));
893
932
 
894
- // Return modification results
895
- const formatted = results.map(r => ({
896
- itemId: r.item?.data?.registryId || r.itemId,
897
- success: r.success,
898
- error: r.error || null
899
- }));
933
+ // The engine records what it could not do rather than throwing — read it,
934
+ // instead of reporting a clean success over a half-applied batch.
935
+ //
936
+ // Read defensively (|| []): the property arrived with the engine-side
937
+ // batchModify fix, so an older studio reports a count and no skip list rather
938
+ // than failing. The batch still works; only the explanation is thinner.
939
+ const skipped = app.lastBatchModifySkipped || [];
940
+ const count = typeof results === 'number' ? results : (Array.isArray(results) ? results.length : 0);
900
941
 
901
- ({ success: formatted.every(r => r.success), results: formatted, count: formatted.length });
942
+ ({
943
+ success: skipped.length === 0 && count === modifications.length,
944
+ count,
945
+ requested: modifications.length,
946
+ skipped: skipped.map(s => ({ itemId: s.itemId, reason: s.reason })),
947
+ ...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ' + modifications.length + ' items; skipped: ' + skipped.map(s => s.itemId + ' (' + s.reason + ')').join(', ') } : {}),
948
+ });
902
949
  `.trim();
903
950
  }
904
951
  /**
@@ -1203,19 +1250,33 @@ export class PinePaperCodeGenerator {
1203
1250
  if (url) {
1204
1251
  return `
1205
1252
  // Import SVG from URL${source === 'figma' ? ' (Figma-normalised)' : ''}
1206
- const response = await fetch('${url}');
1207
- const svgText = await response.text();
1208
- const _r = ${importExpr('svgText')};
1209
- const imported = _r.item;
1210
- if (imported) {
1253
+ // WRAPPED, because these were bare top-level statements with an await. That
1254
+ // parses under the governor, which builds an AsyncFunction, and is a SYNTAX
1255
+ // ERROR on the eval fallback — so importing an SVG by URL never ran at all on
1256
+ // a studio without runGenerated. A paren-led async IIFE parses on both and
1257
+ // still gives the governor a trailing expression to capture.
1258
+ (async function() {
1259
+ let response;
1260
+ try {
1261
+ response = await fetch('${url}');
1262
+ } catch (netErr) {
1263
+ return { success: false, error: 'could not reach ${url} — ' + ((netErr && netErr.message) || 'network request failed') };
1264
+ }
1265
+ if (!response.ok) {
1266
+ return { success: false, error: 'the server refused ${url} — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
1267
+ }
1268
+ const svgText = await response.text();
1269
+ const _r = ${importExpr('svgText')};
1270
+ const imported = _r.item;
1271
+ if (!imported) {
1272
+ return { success: false, error: 'the response from ${url} did not parse as SVG. Check the URL serves the file itself rather than a page around it.' };
1273
+ }
1211
1274
  imported.position = new paper.Point(${position.x}, ${position.y});
1212
1275
  imported.scale(${scale});
1213
1276
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1214
1277
  app.historyManager.saveState();
1215
- ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
1216
- } else {
1217
- throw new Error('Failed to import SVG from URL');
1218
- }
1278
+ return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } };
1279
+ })();
1219
1280
  `.trim();
1220
1281
  }
1221
1282
  if (svgString) {
@@ -1255,12 +1316,41 @@ throw new Error('Either svgString or url must be provided');
1255
1316
  if (!app.importMermaid) return { success: false, error: 'importMermaid not available — diagramSystem missing' };
1256
1317
  const mermaidText = \`${escaped}\`;
1257
1318
  const result = app.importMermaid(mermaidText, ${optsJson});
1319
+
1320
+ // NEVER RETURN THE RAW NODES. They are Paper.js items, and a Paper item
1321
+ // refers to its project, which refers back — JSON.stringify dies on the cycle
1322
+ // with "property '_scope' closes the circle", and the tool reported "Failed
1323
+ // to execute code in browser" for an import that had worked. Summarised to
1324
+ // ids, labels and bounds, which is what a caller can act on anyway.
1325
+ const safeNode = (n) => {
1326
+ const item = n && (n.item || n);
1327
+ const b = item && item.bounds;
1328
+ return {
1329
+ id: (item && item.data && item.data.registryId) || (n && n.id) || null,
1330
+ label: (n && (n.label ?? n.text)) ?? null,
1331
+ bounds: b ? { x: Math.round(b.x), y: Math.round(b.y), width: Math.round(b.width), height: Math.round(b.height) } : null,
1332
+ };
1333
+ };
1334
+ const nodes = (result.nodes || []).map(safeNode);
1335
+ const edges = (result.edges || []).map((e) => ({ from: e?.from ?? e?.source ?? null, to: e?.to ?? e?.target ?? null, label: e?.label ?? null }));
1336
+
1337
+ // A zero-item import is NOT a success. The engine reported success:true with
1338
+ // itemsCreated:0 and the tool passed it on, so a diagram that drew nothing
1339
+ // read as a diagram that drew.
1340
+ if (!nodes.length) {
1341
+ return {
1342
+ success: false,
1343
+ error: 'the mermaid import created no items. The text parsed but produced nothing to draw — check the diagram type is one this build supports, and that the body is not empty.',
1344
+ nodeCount: 0, edgeCount: edges.length, errors: result.errors || [],
1345
+ };
1346
+ }
1347
+
1258
1348
  return {
1259
1349
  success: !!result.success,
1260
- nodeCount: result.nodes ? result.nodes.length : 0,
1261
- edgeCount: result.edges ? result.edges.length : 0,
1262
- nodes: result.nodes || [],
1263
- edges: result.edges || [],
1350
+ nodeCount: nodes.length,
1351
+ edgeCount: edges.length,
1352
+ nodes,
1353
+ edges,
1264
1354
  errors: result.errors || [],
1265
1355
  };
1266
1356
  })();
@@ -1575,8 +1665,11 @@ bg.sendToBack();
1575
1665
  if (backgroundGenerator) {
1576
1666
  codeParts.push(`
1577
1667
  // Execute background generator
1578
- if (typeof app.generators !== 'undefined' && app.generators['${backgroundGenerator}']) {
1579
- app.generators['${backgroundGenerator}']();
1668
+ const _gen = app.generatorRegistry?.get('${backgroundGenerator}');
1669
+ if (typeof _gen === 'function') {
1670
+ _gen();
1671
+ } else if (_gen && typeof _gen.generate === 'function') {
1672
+ _gen.generate();
1580
1673
  }
1581
1674
  `);
1582
1675
  }
@@ -1713,8 +1806,9 @@ results;
1713
1806
  const labelStr = label ? JSON.stringify(label) : 'null';
1714
1807
  return `
1715
1808
  // Create diagram shape: ${shapeType}
1809
+ // POSITIONAL: createShape(shapeType, config). The type was being passed
1810
+ // INSIDE the config object, where nothing reads it.
1716
1811
  const shapeConfig = {
1717
- shapeType: '${shapeType}',
1718
1812
  position: { x: ${position.x}, y: ${position.y} },
1719
1813
  width: ${widthVal},
1720
1814
  height: ${heightVal},
@@ -1722,7 +1816,8 @@ const shapeConfig = {
1722
1816
  style: ${styleStr}
1723
1817
  };
1724
1818
 
1725
- const shape = app.diagramManager.createShape(shapeConfig);
1819
+ const shape = app.diagramSystem.createShape('${shapeType}', shapeConfig);
1820
+ if (!shape) throw new Error('createShape returned nothing for shapeType "${shapeType}" — call pinepaper_get_diagram_shapes for the registered types.');
1726
1821
  const itemId = shape.data?.registryId || shape.id;
1727
1822
  app.historyManager.saveState();
1728
1823
 
@@ -1766,7 +1861,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1766
1861
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1767
1862
 
1768
1863
  const config = ${JSON.stringify(config, null, 2)};
1769
- const connector = app.diagramManager.connect(sourceItem, targetItem, config);
1864
+ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
1770
1865
  const connectorId = connector.data?.registryId || connector.id;
1771
1866
  app.historyManager.saveState();
1772
1867
 
@@ -1794,7 +1889,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1794
1889
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1795
1890
 
1796
1891
  const config = ${configStr};
1797
- const connector = app.diagramManager.connectPorts(
1892
+ const connector = app.diagramSystem.connectPorts(
1798
1893
  sourceItem, '${sourcePort}',
1799
1894
  targetItem, '${targetPort}',
1800
1895
  config
@@ -1811,21 +1906,31 @@ app.historyManager.saveState();
1811
1906
  generateAddPorts(input) {
1812
1907
  const validated = AddPortsInputSchema.parse(input);
1813
1908
  const { itemId, portType, ports, count } = validated;
1814
- const portsStr = ports ? JSON.stringify(ports, null, 2) : 'undefined';
1815
- const countVal = count !== undefined ? count : 'undefined';
1909
+ // THE SCHEMA PROMISES MORE THAN THE ENGINE HAS. `addPorts(item, type)` takes
1910
+ // a type string and lays out that type's port set; there is no per-port
1911
+ // placement and no count. The old emitter passed {portType, ports, count}
1912
+ // as the second argument, which the engine tried to iterate — "t.forEach is
1913
+ // not a function" — so every call failed and the two extra fields looked
1914
+ // like they might have worked. Refused by name instead of dropped.
1915
+ if (ports !== undefined || count !== undefined) {
1916
+ const named = [ports !== undefined && 'ports', count !== undefined && 'count'].filter(Boolean).join(' and ');
1917
+ throw new Error(`add_ports cannot honour ${named}: this engine lays out a whole port SET by type `
1918
+ + `(portType '${portType}'), with no per-port placement or count. Drop ${named}, or place `
1919
+ + 'individual connection points as items and join them with pinepaper_connect.');
1920
+ }
1816
1921
  return `
1817
1922
  // Add ports to item: ${itemId}
1818
1923
  const item = app.getItemById('${itemId}');
1819
1924
  if (!item) throw new Error('Item not found: ${itemId}');
1820
1925
 
1821
- const result = app.diagramManager.addPorts(item, {
1822
- portType: '${portType}',
1823
- ports: ${portsStr},
1824
- count: ${countVal}
1825
- });
1926
+ // The second argument is a TYPE STRING, not a config object. Passing an
1927
+ // object made the engine iterate it as a list — "t.forEach is not a function".
1928
+ const result = app.diagramSystem.addPorts(item, '${portType}');
1826
1929
  app.historyManager.saveState();
1827
1930
 
1828
- ({ itemId: '${itemId}', portsAdded: result.portsAdded || 0, portType: '${portType}' });
1931
+ // addPorts returns the ports it made; older builds returned a count object.
1932
+ const _added = Array.isArray(result) ? result.length : (result?.portsAdded ?? 0);
1933
+ ({ itemId: '${itemId}', portsAdded: _added, portType: '${portType}' });
1829
1934
  `.trim();
1830
1935
  }
1831
1936
  /**
@@ -1838,13 +1943,26 @@ app.historyManager.saveState();
1838
1943
  const optionsStr = options ? JSON.stringify(options, null, 2) : '{}';
1839
1944
  return `
1840
1945
  // Apply auto-layout: ${layoutType}
1946
+ // Wrapped: applyLayout is async, and a bare top-level await only works on the
1947
+ // governed path. A paren-led IIFE also keeps the trailing-expression capture.
1948
+ (async function() {
1841
1949
  const itemIds = ${itemIdsStr};
1842
1950
  const options = ${optionsStr};
1843
1951
 
1844
- const result = app.diagramManager.autoLayout('${layoutType}', itemIds, options);
1952
+ // applyLayout(items, type, options) — ITEMS first, and it takes live items
1953
+ // rather than ids. It is async, and it resolves to undefined: reading
1954
+ // result.itemsAffected off that throws, which is how a layout that worked
1955
+ // reported as a crash.
1956
+ const items = itemIds
1957
+ ? itemIds.map((id) => app.getItemById(id)).filter(Boolean)
1958
+ : (app.itemRegistry?.getAll?.() ?? []);
1959
+ if (!items.length) throw new Error('auto_layout found no items to lay out' + (itemIds ? ' for the given itemIds' : ' on the canvas'));
1960
+
1961
+ await app.diagramSystem.applyLayout(items, '${layoutType}', options);
1845
1962
  app.historyManager.saveState();
1846
1963
 
1847
- ({ layoutType: '${layoutType}', itemsAffected: result.itemsAffected || 0, success: result.success });
1964
+ return { layoutType: '${layoutType}', itemsAffected: items.length, success: true };
1965
+ })();
1848
1966
  `.trim();
1849
1967
  }
1850
1968
  /**
@@ -1857,7 +1975,10 @@ app.historyManager.saveState();
1857
1975
  return `
1858
1976
  // Get available diagram shapes
1859
1977
  const category = ${categoryFilter};
1860
- const shapes = app.diagramManager.getAvailableShapes(category);
1978
+ // The library is the registry; diagramSystem has no getAvailableShapes.
1979
+ const _lib = app.diagramSystem?.shapeLibrary;
1980
+ if (!_lib) throw new Error('app.diagramSystem.shapeLibrary unavailable — update FxTool');
1981
+ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
1861
1982
 
1862
1983
  ({ shapes, count: shapes.length, category: ${categoryFilter} || 'all' });
1863
1984
  `.trim();
@@ -1885,10 +2006,44 @@ const connector = app.getItemById('${connectorId}');
1885
2006
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1886
2007
 
1887
2008
  const updates = ${updatesStr};
1888
- app.diagramManager.updateConnector(connector, updates);
1889
- app.historyManager.saveState();
1890
2009
 
1891
- ({ connectorId: '${connectorId}', updated: true });
2010
+ // THERE IS NO updateConnector. ConnectorManager publishes removeConnector,
2011
+ // findConnector and updateSelectedStyle — nothing that edits one connector's
2012
+ // properties in place. The old emitter called app.diagramSystem.updateConnector
2013
+ // and reported updated:true, so a caller changing a connector's colour got a
2014
+ // success and an unchanged connector.
2015
+ //
2016
+ // Applied directly where the property is a plain Paper.js one, and refused by
2017
+ // name where it is not, rather than claiming a change that did not happen.
2018
+ // The engine gained getConnector(id) / updateConnector(id, style), which is
2019
+ // the call this tool always wanted: addressable by the id a connector is
2020
+ // created and serialised with. Guarded, because a studio predating that still
2021
+ // has to do something sensible rather than throw.
2022
+ if (typeof app.diagramSystem?.updateConnector === 'function') {
2023
+ const res = app.diagramSystem.updateConnector('${connectorId}', updates);
2024
+ if (res && res.ok === false) { return { success: false, error: res.reason || 'the connector could not be updated' }; }
2025
+ app.historyManager.saveState();
2026
+ return { connectorId: '${connectorId}', updated: true, applied: Object.keys(updates) };
2027
+ }
2028
+
2029
+ const _applied = [];
2030
+ const _unsupported = [];
2031
+ for (const [k, v] of Object.entries(updates)) {
2032
+ if (k === 'lineColor' && connector.strokeColor !== undefined) { connector.strokeColor = v; _applied.push(k); }
2033
+ else if (k === 'lineWidth' && connector.strokeWidth !== undefined) { connector.strokeWidth = v; _applied.push(k); }
2034
+ else _unsupported.push(k);
2035
+ }
2036
+ if (_applied.length) { app.historyManager.saveState(); app._scheduleRepaint?.(); }
2037
+ if (_unsupported.length && !_applied.length) {
2038
+ throw new Error(
2039
+ 'this build cannot update ' + _unsupported.join(', ') + ' on an existing connector — '
2040
+ + 'this build has no per-connector update — app.diagramSystem.updateConnector arrived later. '
2041
+ + 'Update the studio for it. Removing and recreating the connector works, but mints a NEW id '
2042
+ + 'and orphans anything holding the old one, so it is a last resort rather than the fix.'
2043
+ );
2044
+ }
2045
+
2046
+ ({ connectorId: '${connectorId}', updated: _applied.length > 0, applied: _applied, unsupported: _unsupported });
1892
2047
  `.trim();
1893
2048
  }
1894
2049
  /**
@@ -1902,7 +2057,7 @@ app.historyManager.saveState();
1902
2057
  const connector = app.getItemById('${connectorId}');
1903
2058
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1904
2059
 
1905
- app.diagramManager.removeConnector(connector);
2060
+ app.diagramSystem.connectorManager.removeConnector(connector);
1906
2061
  app.historyManager.saveState();
1907
2062
 
1908
2063
  ({ connectorId: '${connectorId}', removed: true });
@@ -1918,19 +2073,19 @@ app.historyManager.saveState();
1918
2073
  case 'activate':
1919
2074
  return `
1920
2075
  // Activate diagram mode
1921
- app.diagramManager.activate();
2076
+ app.diagramSystem.activate();
1922
2077
  ({ action: 'activate', active: true });
1923
2078
  `.trim();
1924
2079
  case 'deactivate':
1925
2080
  return `
1926
2081
  // Deactivate diagram mode
1927
- app.diagramManager.deactivate();
2082
+ app.diagramSystem.deactivate();
1928
2083
  ({ action: 'deactivate', active: false });
1929
2084
  `.trim();
1930
2085
  case 'toggle':
1931
2086
  return `
1932
2087
  // Toggle diagram mode
1933
- const isActive = app.diagramManager.toggle();
2088
+ const isActive = app.diagramSystem.toggle();
1934
2089
  ({ action: 'toggle', active: isActive });
1935
2090
  `.trim();
1936
2091
  case 'setMode':
@@ -1938,7 +2093,7 @@ const isActive = app.diagramManager.toggle();
1938
2093
  const shapeStr = shapeType ? `, '${shapeType}'` : '';
1939
2094
  return `
1940
2095
  // Set diagram tool mode
1941
- app.diagramManager.setMode(${modeStr}${shapeStr});
2096
+ app.diagramSystem.setMode(${modeStr}${shapeStr});
1942
2097
  ({ action: 'setMode', mode: ${modeStr}${shapeType ? `, shapeType: '${shapeType}'` : ''} });
1943
2098
  `.trim();
1944
2099
  default:
@@ -2097,7 +2252,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2097
2252
 
2098
2253
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2099
2254
  // Check timeline for active animations
2100
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
2255
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2101
2256
  analysis.hasAnimations = true;
2102
2257
  animationSet.add('timeline');
2103
2258
  }
@@ -2226,6 +2381,17 @@ throw new Error('Unknown diagram mode action: ${action}');
2226
2381
  let code = `
2227
2382
  // Batch execute ${operations.length} operations
2228
2383
  (async function() {
2384
+ // An op's own verdict. Deliberately narrow, matching the server-side rule:
2385
+ // only an explicit success:false or ok:false is a failure, because most ops
2386
+ // return a bare value (an id, a count) and treating a missing field as
2387
+ // refusal would fail every one of them.
2388
+ const _opFailure = (v) => {
2389
+ if (!v || typeof v !== 'object') return null;
2390
+ if (v.success !== false && v.ok !== false) return null;
2391
+ return (typeof v.error === 'string' && v.error)
2392
+ || (typeof v.reason === 'string' && v.reason)
2393
+ || 'the operation reported failure without naming a reason';
2394
+ };
2229
2395
  const results = [];
2230
2396
  const itemIds = [];
2231
2397
  let success = true;
@@ -2250,10 +2416,24 @@ throw new Error('Unknown diagram mode action: ${action}');
2250
2416
  const result${index} = await (async () => {
2251
2417
  ${opCode}
2252
2418
  })();
2253
- results.push({ index: ${index}, success: true, result: result${index} });
2254
- ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
2255
- itemIds.push(result${index}.itemId);
2256
- }` : ''}
2419
+ // NOT THROWING IS NOT SUCCEEDING. An op that RETURNS { success: false }
2420
+ // — a guard that found a facade missing, a preset the engine does not
2421
+ // have, a precondition unmet — raises nothing, so this recorded it as a
2422
+ // success and the batch reported success over it. Measured on
2423
+ // applyAnimatedMask(item, 'star'): the per-op error was perfectly good
2424
+ // and the batch still said everything worked.
2425
+ const failed${index} = _opFailure(result${index});
2426
+ if (failed${index}) {
2427
+ // ATOMIC: throw WITHOUT recording, and let the catch below record it
2428
+ // once. Doing both put two entries in for one failed op.
2429
+ ${isAtomic ? `throw new Error(failed${index});` : `results.push({ index: ${index}, success: false, error: failed${index}, result: result${index} });
2430
+ success = false;`}
2431
+ } else {
2432
+ results.push({ index: ${index}, success: true, result: result${index} });
2433
+ ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
2434
+ itemIds.push(result${index}.itemId);
2435
+ }` : ''}
2436
+ }
2257
2437
  } catch (opError) {
2258
2438
  results.push({ index: ${index}, success: false, error: opError.message });
2259
2439
  ${isAtomic ? 'throw opError;' : 'success = false;'}
@@ -2265,7 +2445,14 @@ throw new Error('Unknown diagram mode action: ${action}');
2265
2445
  if (app.historyManager) app.historyManager.saveState();
2266
2446
 
2267
2447
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2268
- return { success, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
2448
+ // Name WHICH ops failed on the result itself. A caller reading a failed
2449
+ // 60-op batch should not have to scan sixty entries to learn that op 41
2450
+ // could not find a preset.
2451
+ const _failed = results.filter((r) => !r.success);
2452
+ return { success, itemIds, results, operationCount: ${operations.length},
2453
+ failedCount: _failed.length,
2454
+ ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
2455
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
2269
2456
  } catch (e) {
2270
2457
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2271
2458
  return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
@@ -2322,8 +2509,12 @@ return { itemId };
2322
2509
  const modifyProps = JSON.stringify(op.properties || {});
2323
2510
  return `
2324
2511
  const targetId = ${itemRef};
2325
- app.select(targetId);
2326
- app.modify(${modifyProps});
2512
+ // modifyItem(id, changes), not select() + modify(). modify() edits the
2513
+ // SELECTION, so an id that does not resolve left the PREVIOUS selection to be
2514
+ // edited instead — and this then reported modified:true either way.
2515
+ if (app.modifyItem(targetId, ${modifyProps}) === false) {
2516
+ return { success: false, itemId: targetId, error: 'no item ' + targetId + ' — nothing was modified' };
2517
+ }
2327
2518
  return { itemId: targetId, modified: true };
2328
2519
  `;
2329
2520
  case 'animate':
@@ -2398,9 +2589,16 @@ return { success: true, generator: '${genName}'${op.generatorRegion ? ', region:
2398
2589
  const h = op.height || 1080;
2399
2590
  const sizeArg = op.preset ? `'${op.preset}'` : `{ width: ${w}, height: ${h} }`;
2400
2591
  return `
2401
- app.setCanvasSize(${sizeArg});
2592
+ // Read the verdict, as the standalone tool now does: an unknown preset or an
2593
+ // out-of-range dimension is refused by name, and reporting the size we ASKED
2594
+ // for left the batch claiming a resize that never happened.
2595
+ const _cs = app.setCanvasSize(${sizeArg});
2596
+ if (_cs && _cs.ok === false) {
2597
+ return { success: false, error: _cs.reason || 'the canvas size was refused',
2598
+ ...(_cs.known ? { knownPresets: _cs.known } : {}) };
2599
+ }
2402
2600
  await new Promise(r => setTimeout(r, 50));
2403
- return { success: true, width: ${w}, height: ${h} };
2601
+ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.height) || ${h} };
2404
2602
  `;
2405
2603
  }
2406
2604
  case 'keyframe_animate': {
@@ -2457,7 +2655,15 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
2457
2655
  const targetId = ${effItemRef};
2458
2656
  const item = app.getItemById(targetId);
2459
2657
  if (!item) throw new Error('Item not found: ' + targetId);
2460
- app.applyEffect(item, '${op.effectType || 'sparkle'}', ${effParams});
2658
+ // PROPAGATE the engine's verdict. This discarded it and returned its own
2659
+ // shape, so an effect the engine refused — an unknown preset, a facade that
2660
+ // is not loaded — was recorded as applied. The batch cannot report what the
2661
+ // op never tells it.
2662
+ const _eff = app.applyEffect(item, '${op.effectType || 'sparkle'}', ${effParams});
2663
+ if (_eff && (_eff.success === false || _eff.ok === false)) {
2664
+ return { success: false, itemId: targetId, effectType: '${op.effectType || 'sparkle'}',
2665
+ error: _eff.error || _eff.reason || 'the effect was refused without a reason' };
2666
+ }
2461
2667
  return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
2462
2668
  `;
2463
2669
  }
@@ -2551,7 +2757,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2551
2757
  }
2552
2758
  generateAgentExport(input) {
2553
2759
  const validated = AgentExportInputSchema.parse(input);
2554
- const { platform, format, quality, framing, duration, estimateOnly, scale } = validated;
2760
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps } = validated;
2555
2761
  const qualityLevel = quality || 'standard';
2556
2762
  const videoDuration = duration ?? 5;
2557
2763
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -2669,7 +2875,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2669
2875
  const format = '${exportFormat}';
2670
2876
  const quality = '${qualityLevel}';
2671
2877
  const framing = '${framing}';
2672
- const settings = ${JSON.stringify(qualitySettings)};
2878
+ // An explicit fps wins over the tier's. The tiers bundle frame rate with
2879
+ // compression (draft 15 / standard 30 / high 60), so "higher quality" also
2880
+ // doubled the frame count and the render time — a surprise the schema used
2881
+ // to hide because there was no way to ask for one without the other.
2882
+ const settings = ${JSON.stringify(fps !== undefined ? { ...qualitySettings, fps } : qualitySettings)};
2673
2883
  const dimensions = ${JSON.stringify(scaled)};
2674
2884
 
2675
2885
  // Preflight: same resolved settings as the real export, but render nothing.
@@ -2736,7 +2946,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2736
2946
  try {
2737
2947
  switch (format) {
2738
2948
  case 'svg':
2739
- const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVG();
2949
+ const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();
2740
2950
  result = {
2741
2951
  success: true,
2742
2952
  platform,
@@ -2991,7 +3201,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2991
3201
 
2992
3202
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2993
3203
  // Check timeline for active animations
2994
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
3204
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2995
3205
  analysis.hasAnimations = true;
2996
3206
  animationSet.add('timeline');
2997
3207
  }
@@ -3525,7 +3735,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3525
3735
  }
3526
3736
 
3527
3737
  try {
3528
- app.mapSystem.stopAnimations({
3738
+ app.mapSystem.stopRegionAnimations({
3529
3739
  regions: ${regionsStr},
3530
3740
  resetColors: ${validated.resetColors !== false}
3531
3741
  });
@@ -3569,7 +3779,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3569
3779
  }
3570
3780
 
3571
3781
  try {
3572
- const result = app.mapSystem.exportRegionCSV({
3782
+ const result = app.mapSystem.exportRegionDataCSV({
3573
3783
  includeHighlighted: ${validated.includeHighlighted !== false},
3574
3784
  includeSelected: ${validated.includeSelected !== false},
3575
3785
  includeColors: ${validated.includeColors !== false},
@@ -3598,8 +3808,9 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3598
3808
  }
3599
3809
 
3600
3810
  try {
3601
- const result = app.mapSystem.importRegionCSV({
3602
- csvText: \`${escapedCsv}\`,
3811
+ // importRegionDataCSV(csvText, options) — the TEXT is positional, and it
3812
+ // was being passed inside the options bag where nothing read it.
3813
+ const result = app.mapSystem.importRegionDataCSV(\`${escapedCsv}\`, {
3603
3814
  applyColors: ${validated.applyColors !== false},
3604
3815
  applyHighlight: ${validated.applyHighlight !== false},
3605
3816
  applySelection: ${validated.applySelection !== false}
@@ -3908,7 +4119,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3908
4119
  if (item.strokeColor) itemData.properties.strokeColor = item.strokeColor.toCSS ? item.strokeColor.toCSS() : item.strokeColor;
3909
4120
  if (item.strokeWidth) itemData.properties.strokeWidth = item.strokeWidth;
3910
4121
  if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3911
- if (item.rotation) itemData.properties.rotation = item.rotation;
4122
+ // TRUE ROTATION, not the property. create() BAKES an angle into the path
4123
+ // segments under Paper's applyMatrix default, so item.rotation reads back 0 on
4124
+ // an item that is visibly rotated. Reporting that 0 is why "I could not rotate
4125
+ // a rectangle" was filed against an engine that rotates correctly. The engine
4126
+ // records the baked offset at item.data._bakedRotation and sums the two itself
4127
+ // (PinePaper.js does exactly this); read the same sum rather than the half of
4128
+ // it that happens to be zero.
4129
+ const _rot = typeof app.authoredRotation === 'function' ? app.authoredRotation(item) : ((item.data?._bakedRotation || 0) + (item.rotation || 0));
4130
+ if (_rot) itemData.properties.rotation = _rot;
3912
4131
  if (item.data?.content) itemData.properties.content = item.data.content;
3913
4132
  if (item.visible === false) itemData.properties.visible = false;
3914
4133
 
@@ -3962,7 +4181,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3962
4181
  }
3963
4182
 
3964
4183
  // Get background color and canvas size
3965
- const backgroundColor = app.getBackgroundColor ? app.getBackgroundColor() : null;
4184
+ const backgroundColor = app.canvasEl?.style?.backgroundColor || null;
3966
4185
  const canvasSize = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
3967
4186
 
3968
4187
  return {
@@ -4026,10 +4245,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4026
4245
  }
4027
4246
 
4028
4247
  try {
4029
- const result = app.mapSystem.exportOriginalGeoJSON({
4030
- download: ${download},
4031
- filename: ${JSON.stringify(filename)}
4032
- });
4248
+ // Two methods, not one with a flag: getOriginalGeoJSON() returns the data,
4249
+ // downloadOriginalGeoJSON(filename) hands it to the browser.
4250
+ // exportOriginalGeoJSON has never existed.
4251
+ const result = ${download}
4252
+ ? app.mapSystem.downloadOriginalGeoJSON(${JSON.stringify(filename)})
4253
+ : app.mapSystem.getOriginalGeoJSON();
4254
+ if (result === null || result === undefined) {
4255
+ return { success: false, error: 'no map is loaded, so there is no source GeoJSON to return' };
4256
+ }
4033
4257
  return result;
4034
4258
  } catch (error) {
4035
4259
  return { success: false, error: error.message };
@@ -4049,7 +4273,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4049
4273
  }
4050
4274
 
4051
4275
  try {
4052
- const result = app.mapSystem.getSourceInfo();
4276
+ const result = app.mapSystem.getMapSourceInfo();
4053
4277
  return result;
4054
4278
  } catch (error) {
4055
4279
  return { success: false, error: error.message };
@@ -4146,9 +4370,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4146
4370
  generateExecuteCustomCode(input) {
4147
4371
  const validated = ExecuteCustomCodeInputSchema.parse(input);
4148
4372
  const description = validated.description || 'Execute custom code';
4373
+ // ASYNC, because the code inside is not ours.
4374
+ //
4375
+ // This wrapped the caller's snippet in a SYNC IIFE, which does two things
4376
+ // to async work. A top-level `await` is a syntax error inside a
4377
+ // non-async function, so a snippet that awaits anything fails to parse at
4378
+ // all. And async work started without await returns a promise nobody
4379
+ // holds: the IIFE returns immediately, the tool reports success, and the
4380
+ // next call reads a canvas where the work has not happened yet.
4381
+ //
4382
+ // That is the whole async half of the engine — export, image import,
4383
+ // rigging bake, layout — unreachable from the one tool whose entire
4384
+ // purpose is reaching things the tool surface does not cover.
4385
+ //
4386
+ // An async IIFE is still a `(`-led expression statement, so the governor
4387
+ // captures its value, and executeCode's eval fallback already resolves a
4388
+ // returned promise. Both paths await it; neither needed changing.
4149
4389
  return `
4150
4390
  // ${description}
4151
- (function() {
4391
+ (async function() {
4152
4392
  try {
4153
4393
  ${validated.code}
4154
4394
  } catch (error) {
@@ -4248,6 +4488,33 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4248
4488
  }
4249
4489
  })();
4250
4490
  `.trim();
4491
+ }
4492
+ /**
4493
+ * What THIS item can animate, from the engine's own table.
4494
+ *
4495
+ * The table is derived from the applier rather than written beside it, so it
4496
+ * cannot drift from what actually gets applied — and it is filtered by item
4497
+ * type, because the trim/dash four only exist on a path and a rectangle has
4498
+ * no fontSize.
4499
+ *
4500
+ * `interpolates: false` is passed through VERBATIM and never flattened away.
4501
+ * `content` and `blendMode` are DISCRETE — they hold their value until the
4502
+ * next keyframe rather than blending toward it — and treating content as
4503
+ * tweenable is what made every word in a cycler appear half a beat early.
4504
+ * A list that says only "animatable" rebuilds that bug in the next agent.
4505
+ */
4506
+ generateListItemAnimatableProperties(itemId) {
4507
+ return `
4508
+ // Animatable properties of ${JSON.stringify(itemId)}
4509
+ (function() {
4510
+ if (typeof app.listAnimatableProperties !== 'function') {
4511
+ return { success: false, error: 'app.listAnimatableProperties unavailable — update the studio. The keyframe tool lists the properties it accepts in its own description meanwhile.' };
4512
+ }
4513
+ const item = app.getItemById(${JSON.stringify(itemId)});
4514
+ if (!item) { return { success: false, error: 'no item ${itemId}' }; }
4515
+ const r = app.listAnimatableProperties(item);
4516
+ return { success: true, ...r };
4517
+ })();`.trim();
4251
4518
  }
4252
4519
  generateGetAnimatableProperties() {
4253
4520
  return `
@@ -4402,7 +4669,41 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4402
4669
  return { error: 'Image tools not available. Make sure PinePaper Studio is loaded.' };
4403
4670
  }
4404
4671
  try {
4405
- const entry = await app.imageTools.uploadFromURL('${url}');
4672
+ // FETCH IT OURSELVES FIRST, to find out WHY when it fails.
4673
+ //
4674
+ // uploadFromURL sets img.src and rejects with a bare "Failed to load image
4675
+ // from URL" — an <img> onerror genuinely carries no reason, so that message
4676
+ // is the browser's limit rather than the engine being unhelpful. fetch()
4677
+ // has the reason: a status code, a CORS rejection, a refused connection, a
4678
+ // DNS failure. A caller cannot debug what the tool will not show them.
4679
+ //
4680
+ // The bytes then go in as a DATA URL, which img.src loads without touching
4681
+ // the network again — so a host that allows fetch but blocks the image
4682
+ // load (CORS on <img>, a tainted-canvas refusal) now works too.
4683
+ let src = '${url}';
4684
+ if (!src.startsWith('data:')) {
4685
+ let res;
4686
+ try {
4687
+ res = await fetch(src);
4688
+ } catch (netErr) {
4689
+ return { success: false, error: 'could not reach ' + src + ' — ' + (netErr && netErr.message ? netErr.message : 'network request failed')
4690
+ + '. This is the browser\'s network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
4691
+ }
4692
+ if (!res.ok) {
4693
+ return { success: false, error: 'the server refused ' + src + ' — HTTP ' + res.status + ' ' + (res.statusText || ''), status: res.status };
4694
+ }
4695
+ const blob = await res.blob();
4696
+ if (!blob.type.startsWith('image/')) {
4697
+ return { success: false, error: src + ' returned ' + (blob.type || 'an unknown content type') + ', not an image. Check the URL serves the file itself rather than a web page around it.', contentType: blob.type };
4698
+ }
4699
+ src = await new Promise((resolve, reject) => {
4700
+ const fr = new FileReader();
4701
+ fr.onloadend = () => resolve(fr.result);
4702
+ fr.onerror = () => reject(new Error('could not read the downloaded image'));
4703
+ fr.readAsDataURL(blob);
4704
+ });
4705
+ }
4706
+ const entry = await app.imageTools.uploadFromURL(src);
4406
4707
  const opts = ${optsLiteral};
4407
4708
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
4408
4709
  ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
@@ -4610,7 +4911,21 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
4610
4911
  // Toggle loop mode
4611
4912
  (function() {
4612
4913
  ${guard}
4613
- app.sceneManager.setLoop(${input.enabled ?? true});
4914
+ // chainLoop is a PROPERTY, set by createChain({loop}) and read by the chain
4915
+ // advance. setLoop has never existed, so this reported a toggle it had not
4916
+ // performed — and a chain built without loop went on not looping.
4917
+ if (typeof app.sceneManager.setLoop === 'function') {
4918
+ app.sceneManager.setLoop(${input.enabled ?? true});
4919
+ } else if ('chainLoop' in app.sceneManager) {
4920
+ app.sceneManager.chainLoop = ${input.enabled ?? true};
4921
+ } else {
4922
+ return { success: false, action: 'toggle_loop',
4923
+ error: 'this build has no chain-loop control to set. Pass loop:true to create_chain instead, which is where the setting is read from.' };
4924
+ }
4925
+ if (!app.sceneManager.sceneChain || !app.sceneManager.sceneChain.length) {
4926
+ return { success: true, action: 'toggle_loop', enabled: ${input.enabled ?? true},
4927
+ note: 'no chain exists yet — this setting applies to the next one created, and create_chain takes loop directly.' };
4928
+ }
4614
4929
  return { success: true, action: 'toggle_loop', enabled: ${input.enabled ?? true} };
4615
4930
  })();
4616
4931
  `.trim();
@@ -5380,8 +5695,23 @@ ${resolve}
5380
5695
  (function() {
5381
5696
  const itemIds = ${JSON.stringify(input.itemIds || [])};
5382
5697
  const opts = ${JSON.stringify(opts)};
5383
- const precomp = app.createPrecomp(itemIds, opts);
5384
- return { success: true, action: 'create', precompId: precomp?.id || precomp, name: ${JSON.stringify(input.name || '')} };
5698
+
5699
+ // createPrecomp wants LIVE ITEMS. Handing it registry ids put strings into
5700
+ // Paper.js's insertChildren, which fails as "l._remove is not a function" —
5701
+ // a minified internal, from a schema that promises ids work. Resolved here,
5702
+ // and a missing one is named rather than silently dropped from the group.
5703
+ const items = [];
5704
+ const missing = [];
5705
+ for (const id of itemIds) {
5706
+ const item = app.getItemById(id);
5707
+ if (item) items.push(item); else missing.push(id);
5708
+ }
5709
+ if (missing.length) return { success: false, error: 'precomp: no such item(s): ' + missing.join(', ') };
5710
+ if (!items.length) return { success: false, error: 'precomp needs at least one item' };
5711
+
5712
+ const precomp = app.createPrecomp(items, opts);
5713
+ if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
5714
+ return { success: true, action: 'create', precompId: precomp?.id || precomp, itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
5385
5715
  })();`.trim();
5386
5716
  }
5387
5717
  case 'add':
@@ -5473,7 +5803,7 @@ ${resolve}
5473
5803
  bounds: item.bounds ? { x: item.bounds.x, y: item.bounds.y, width: item.bounds.width, height: item.bounds.height } : null,
5474
5804
  visible: item.visible,
5475
5805
  opacity: item.opacity,
5476
- rotation: item.rotation,
5806
+ rotation: typeof app.authoredRotation === 'function' ? app.authoredRotation(item) : ((item.data?._bakedRotation || 0) + (item.rotation || 0)),
5477
5807
  selected: item.selected,
5478
5808
  };
5479
5809
  })();`.trim();
@@ -5809,7 +6139,11 @@ ${resolve}
5809
6139
  // SPRITE SHEETS
5810
6140
  // ===========================================================================
5811
6141
  generateSpriteSheet(input) {
5812
- const guard = `if (!app.spriteSheetSystem) return { error: 'SpriteSheetSystem not available' };`;
6142
+ // spriteSystem is _defineLazyHeavy: undefined until ensureHeavyModules()
6143
+ // lands, so the bare guard reported "not available" on a fresh session for
6144
+ // a studio that has it. Same bug as app.exportEngine in 1.6.9.
6145
+ const guard = `if (!app.spriteSystem && typeof app.ensureHeavyModules === 'function') { try { await app.ensureHeavyModules(); } catch (_) {} }
6146
+ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
5813
6147
  switch (input.action) {
5814
6148
  case 'generate': {
5815
6149
  const opts = {};
@@ -5829,7 +6163,7 @@ ${resolve}
5829
6163
  // Generate sprite sheet from skeleton
5830
6164
  (async function() {
5831
6165
  ${guard}
5832
- const sheet = await app.spriteSheetSystem.generateSpriteSheet(${JSON.stringify(input.skeletonId || '')}, ${JSON.stringify(opts)});
6166
+ const sheet = await app.generateSpriteSheet(${JSON.stringify(input.skeletonId || '')}, ${JSON.stringify(opts)});
5833
6167
  return { success: true, action: 'generate', spriteSheetId: sheet.id, name: sheet.name, width: sheet.atlasWidth, height: sheet.atlasHeight, frameCount: sheet.frames?.size ?? sheet.frames?.length ?? 0 };
5834
6168
  })();`.trim();
5835
6169
  }
@@ -5849,7 +6183,7 @@ ${resolve}
5849
6183
  // Play sprite sheet animation
5850
6184
  (async function() {
5851
6185
  ${guard}
5852
- const player = await app.spriteSheetSystem.playSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, ${JSON.stringify(opts)});
6186
+ const player = await app.playSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, ${JSON.stringify(opts)});
5853
6187
  return { success: true, action: 'play', playerId: player.id };
5854
6188
  })();`.trim();
5855
6189
  }
@@ -5858,7 +6192,7 @@ ${resolve}
5858
6192
  // Export sprite sheet
5859
6193
  (async function() {
5860
6194
  ${guard}
5861
- const result = await app.spriteSheetSystem.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: '${input.format || 'png'}', download: true, includeMetadata: true });
6195
+ const result = await app.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: '${input.format || 'png'}', download: true, includeMetadata: true });
5862
6196
  return { success: true, action: 'export', format: '${input.format || 'png'}' };
5863
6197
  })();`.trim();
5864
6198
  default:
@@ -6216,7 +6550,8 @@ ${resolve}
6216
6550
  // Toggle rulers
6217
6551
  (function() {
6218
6552
  ${guard}
6219
- app.measurementSystem.setRulersVisible(${enabled});
6553
+ // showRulers()/hideRulers(), not a boolean setter — setRulersVisible never existed.
6554
+ app.measurementSystem[${enabled} ? 'showRulers' : 'hideRulers']();
6220
6555
  return { success: true, action: 'set_rulers', enabled: ${enabled} };
6221
6556
  })();`.trim();
6222
6557
  }
@@ -6226,7 +6561,8 @@ ${resolve}
6226
6561
  // Toggle grid
6227
6562
  (function() {
6228
6563
  ${guard}
6229
- app.measurementSystem.setGridVisible(${enabled});
6564
+ // showGrid()/hideGrid(), not a boolean setter — setGridVisible never existed.
6565
+ app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
6230
6566
  return { success: true, action: 'set_grid', enabled: ${enabled} };
6231
6567
  })();`.trim();
6232
6568
  }
@@ -6241,17 +6577,21 @@ ${resolve}
6241
6577
  const entry = app.itemRegistry.get(${itemIdStr});
6242
6578
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
6243
6579
  const b = entry.item.bounds;
6244
- return { success: true, action: 'get_dimensions', itemId: ${itemIdStr}, x: b.x, y: b.y, width: b.width, height: b.height, rotation: entry.item.rotation || 0 };
6580
+ return { success: true, action: 'get_dimensions', itemId: ${itemIdStr}, x: b.x, y: b.y, width: b.width, height: b.height, rotation: typeof app.authoredRotation === 'function' ? app.authoredRotation(entry.item) : ((entry.item.data?._bakedRotation || 0) + (entry.item.rotation || 0)) };
6245
6581
  })();`.trim();
6246
6582
  }
6247
6583
  case 'set_snap': {
6248
- const enabled = input.enabled !== false;
6584
+ // `enabled` is deliberately unread: there is no mode to set either way.
6249
6585
  return `
6250
- // Toggle snap to grid
6586
+ // Snap to grid — refused, see below
6251
6587
  (function() {
6252
6588
  ${guard}
6253
- app.measurementSystem.setSnapToUnitEnabled(${enabled});
6254
- return { success: true, action: 'set_snap', enabled: ${enabled} };
6589
+ // NO EQUIVALENT. MeasurementSystem publishes snapCoordinate(), which snaps a
6590
+ // coordinate you hand it, and nothing that turns snapping on as a mode —
6591
+ // setSnapToUnitEnabled has never existed, so this reported success over a
6592
+ // setting that was never changed. Refused by name, with the thing that works.
6593
+ return { success: false, action: 'set_snap',
6594
+ error: 'this build has no snap-to-unit MODE to switch: the measurement system snaps a coordinate on request rather than holding a setting. Snap positions yourself before creating or moving items, or set a grid with the grid action and place on its multiples.' };
6255
6595
  })();`.trim();
6256
6596
  }
6257
6597
  default:
@@ -6746,10 +7086,25 @@ ${guard}
6746
7086
  });
6747
7087
  return `
6748
7088
  // Display text style: ${input.styleKey} — stacked-layer title, id adopted from the text
6749
- (function() {
7089
+ (async function() {
6750
7090
  if (typeof app.applyTextStyle !== 'function') { return { success: false, error: 'app.applyTextStyle unavailable — update FxTool to a text-styles build' }; }
7091
+ // LOAD THE FACES FIRST. The decorative stylesheet is fetched on pointerenter
7092
+ // over the style grid — a gesture no agent ever makes — so every decorative
7093
+ // family was missing in production and a style asking for one silently drew
7094
+ // the fallback and reported success. ensureFontsLoaded is the fix; guarded,
7095
+ // because a studio without it behaves as it always did.
7096
+ if (typeof app.ensureFontsLoaded === 'function') {
7097
+ try { await app.ensureFontsLoaded('decorative'); } catch (_) { /* the pending flag below still reports it */ }
7098
+ }
6751
7099
  const r = app.applyTextStyle(${S(input.itemId)}, ${S(input.styleKey)}, ${opts});
6752
7100
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'style failed' }; }
7101
+ // fontPending: the face is not there YET, so this drew in the fallback. Said
7102
+ // out loud rather than left to be discovered in the export.
7103
+ if (r.fontPending) {
7104
+ return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers,
7105
+ palette: r.palette, bounds: r.bounds, fontPending: true,
7106
+ warning: 'the face for this style had not loaded, so it drew in a FALLBACK font. Re-apply the style, or call pinepaper_font check with the family and your text to see what is really being used.' };
7107
+ }
6753
7108
  // The styled group ADOPTS the text item's registry id — r.id is the SAME id
6754
7109
  // the caller passed, so their handle keeps working. Say so in the result.
6755
7110
  return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers, palette: r.palette, bounds: r.bounds };
@@ -6763,8 +7118,19 @@ ${guard}
6763
7118
  const r = app.setFontAxes(${S(input.itemId)}, ${S(input.axes)});
6764
7119
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'axes failed' }; }
6765
7120
  // rejected is the interesting half: an axis silently ignored is the failure
6766
- // mode this surface exists to prevent.
6767
- return { success: true, action: 'set_font_axes', applied: r.applied, rejected: r.rejected || [] };
7121
+ // mode this surface exists to prevent. And NOTHING applied while something
7122
+ // was rejected is not a success — it is the call doing nothing under a
7123
+ // success, which is the same shape as an empty batch reporting ok.
7124
+ const _applied = r.applied || {};
7125
+ const _rejected = r.rejected || [];
7126
+ const _appliedCount = Array.isArray(_applied) ? _applied.length : Object.keys(_applied).length;
7127
+ if (_appliedCount === 0 && _rejected.length) {
7128
+ return { success: false, action: 'set_font_axes', applied: _applied, rejected: _rejected,
7129
+ error: 'no axis was applied. The font does not have: ' + _rejected.join(', ')
7130
+ + (r.note ? '. ' + r.note : '') };
7131
+ }
7132
+ return { success: true, action: 'set_font_axes', applied: _applied, rejected: _rejected,
7133
+ ...(r.note ? { note: r.note } : {}) };
6768
7134
  })();`.trim();
6769
7135
  }
6770
7136
  case 'list_styles': {
@@ -7607,7 +7973,15 @@ ${needWorld}
7607
7973
  if (typeof app.storyFromText !== 'function') { return { success: false, error: 'app.storyFromText unavailable — update FxTool' }; }
7608
7974
  const r = await app.storyFromText(${S(input.text)}, ${opts});
7609
7975
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the story did not assemble' }; }
7610
- return { success: true, ...r };
7976
+ // NAMED FIELDS, not a spread. assembleStory returns its parts as live Paper
7977
+ // Groups, and a Paper item's graph reaches the editor's EVENT HANDLERS.
7978
+ // Structured clone — which is what the bridge uses — refuses to carry a
7979
+ // function, so the whole result threw DataCloneError and the message did not
7980
+ // survive the wrapping: an empty error string for a story that assembled
7981
+ // fine in-page. Not a cycle; JSON.stringify succeeded on this the whole time.
7982
+ // The groups have to stay for in-page callers, so the wrapper takes what
7983
+ // travels: the spec, the ids, the count.
7984
+ return { success: true, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };
7611
7985
  })();`.trim();
7612
7986
  case 'apply_spec':
7613
7987
  return `
@@ -8031,11 +8405,23 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
8031
8405
  return { success: true, action: 'create_skeleton', skeletonId: skeletonId };`);
8032
8406
  }
8033
8407
  case 'add_bone': {
8408
+ // DEGREES IN, RADIANS OUT. The schema says degrees and every other
8409
+ // angle on this surface is degrees, but RiggingSystem stores what it is
8410
+ // given and _solveFKRecursive feeds it straight to Math.cos/Math.sin.
8411
+ // So a documented 90 was read as 90 radians and the character exploded
8412
+ // on the first pose — silently, because a wrong pose is not an error.
8413
+ // Converted here so the tool keeps the vocabulary it advertises.
8414
+ // BOTH SPELLINGS, deliberately. The engine's _angleRad prefers
8415
+ // `angleDegrees` and converts it, so the new build reads the caller's
8416
+ // intent verbatim instead of trusting arithmetic done out here. `angle`
8417
+ // carries the same value already in radians, which is what an older
8418
+ // build reads — it has no angleDegrees and would see 0. Identical
8419
+ // result on both, and the units are stated rather than implied.
8034
8420
  const config = S({
8035
8421
  ...(input.name !== undefined ? { name: input.name } : {}),
8036
8422
  ...(input.parentBoneId !== undefined ? { parentBoneId: input.parentBoneId } : {}),
8037
8423
  ...(input.length !== undefined ? { length: input.length } : {}),
8038
- ...(input.angle !== undefined ? { angle: input.angle } : {}),
8424
+ ...(input.angle !== undefined ? { angleDegrees: input.angle, angle: (input.angle * Math.PI) / 180 } : {}),
8039
8425
  ...(input.flexibility !== undefined ? { flexibility: input.flexibility } : {}),
8040
8426
  ...(input.segments !== undefined ? { segments: input.segments } : {}),
8041
8427
  });
@@ -8075,7 +8461,15 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
8075
8461
  ...(input.movingHold !== undefined ? { movingHold: input.movingHold } : {}),
8076
8462
  ...(input.holdDrift !== undefined ? { holdDrift: input.holdDrift } : {}),
8077
8463
  });
8078
- return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
8464
+ // Same unit conversion as add_bone: an inline pose is { boneId: angleDeg }
8465
+ // by the schema, and the solver reads radians. A SAVED pose is a string
8466
+ // id and is passed through untouched — those angles are already in the
8467
+ // engine's own units.
8468
+ const poseArg = (input.pose !== null && typeof input.pose === 'object')
8469
+ ? S(Object.fromEntries(Object.entries(input.pose)
8470
+ .map(([bone, deg]) => [bone, (deg * Math.PI) / 180])))
8471
+ : S(input.pose);
8472
+ return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${poseArg}, ${S(input.easing ?? 'linear')}, ${opts});
8079
8473
  if (app.historyManager) app.historyManager.saveState();
8080
8474
  if (!ok) { return { success: false, action: 'add_pose_keyframe', error: "no such skeleton, or 'pose' named a saved pose that does not exist. Pass an inline { boneId: angleDeg } map instead, or list_poses for the saved ones" }; }
8081
8475
  return { success: true, action: 'add_pose_keyframe', time: ${input.time} };`);