@pinepaper.studio/mcp-server 1.6.10 → 1.6.12

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 (63) hide show
  1. package/README.md +94 -1
  2. package/dist/design/design-systems.d.ts +12 -3
  3. package/dist/design/design-systems.d.ts.map +1 -1
  4. package/dist/design/design-systems.js +12 -11
  5. package/dist/design/design-systems.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/tools/compact-descriptions.js +1 -1
  10. package/dist/tools/compact-descriptions.js.map +1 -1
  11. package/dist/tools/definitions.d.ts +1 -1
  12. package/dist/tools/definitions.d.ts.map +1 -1
  13. package/dist/tools/definitions.js +189 -55
  14. package/dist/tools/definitions.js.map +1 -1
  15. package/dist/tools/engine-methods.d.ts.map +1 -1
  16. package/dist/tools/engine-methods.js +7 -0
  17. package/dist/tools/engine-methods.js.map +1 -1
  18. package/dist/tools/engine-surface.d.ts +1 -1
  19. package/dist/tools/engine-surface.d.ts.map +1 -1
  20. package/dist/tools/engine-surface.js +25 -6
  21. package/dist/tools/engine-surface.js.map +1 -1
  22. package/dist/tools/handlers.js +9 -0
  23. package/dist/tools/handlers.js.map +1 -1
  24. package/dist/tools/minimal-descriptions.js +1 -1
  25. package/dist/tools/minimal-descriptions.js.map +1 -1
  26. package/dist/tools/shape-params.d.ts +36 -0
  27. package/dist/tools/shape-params.d.ts.map +1 -0
  28. package/dist/tools/shape-params.js +159 -0
  29. package/dist/tools/shape-params.js.map +1 -0
  30. package/dist/tools/stick-vocabulary.d.ts +26 -0
  31. package/dist/tools/stick-vocabulary.d.ts.map +1 -0
  32. package/dist/tools/stick-vocabulary.js +102 -0
  33. package/dist/tools/stick-vocabulary.js.map +1 -0
  34. package/dist/types/code-generator.d.ts +15 -0
  35. package/dist/types/code-generator.d.ts.map +1 -1
  36. package/dist/types/code-generator.js +556 -76
  37. package/dist/types/code-generator.js.map +1 -1
  38. package/dist/types/schemas.d.ts +97 -26
  39. package/dist/types/schemas.d.ts.map +1 -1
  40. package/dist/types/schemas.js +18 -11
  41. package/dist/types/schemas.js.map +1 -1
  42. package/dist/vendor/design/design-style.d.ts +3 -1
  43. package/dist/vendor/design/design-style.d.ts.map +1 -1
  44. package/dist/vendor/design/design-style.js +37 -2
  45. package/dist/vendor/design/design-style.js.map +1 -1
  46. package/dist/vendor/design/design-systems-dtcg.d.ts +9 -0
  47. package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -1
  48. package/dist/vendor/design/design-systems-dtcg.js +53 -1
  49. package/dist/vendor/design/design-systems-dtcg.js.map +1 -1
  50. package/dist/vendor/design/device-mockup-generator.d.ts +83 -0
  51. package/dist/vendor/design/device-mockup-generator.d.ts.map +1 -0
  52. package/dist/vendor/design/device-mockup-generator.js +634 -0
  53. package/dist/vendor/design/device-mockup-generator.js.map +1 -0
  54. package/dist/vendor/design/recursive-subdivision-generator.d.ts +79 -0
  55. package/dist/vendor/design/recursive-subdivision-generator.d.ts.map +1 -0
  56. package/dist/vendor/design/recursive-subdivision-generator.js +319 -0
  57. package/dist/vendor/design/recursive-subdivision-generator.js.map +1 -0
  58. package/dist/vendor/design/textile-weave-generator.d.ts +140 -0
  59. package/dist/vendor/design/textile-weave-generator.d.ts.map +1 -0
  60. package/dist/vendor/design/textile-weave-generator.js +568 -0
  61. package/dist/vendor/design/textile-weave-generator.js.map +1 -0
  62. package/manifest.json +1 -1
  63. package/package.json +8 -2
@@ -86,7 +86,7 @@ function generateCreateItemCode(itemType, position, properties) {
86
86
  const params = {
87
87
  x: position.x,
88
88
  y: position.y,
89
- ...baseProperties,
89
+ ...withRadiusAxes(baseProperties),
90
90
  };
91
91
  // Handle simple solid colors in params
92
92
  if (color && !isGradient(color)) {
@@ -128,6 +128,29 @@ function generateCreateItemCode(itemType, position, properties) {
128
128
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
129
129
  if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
130
130
  }
131
+ // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
132
+ //
133
+ // The engine names a bad anchor through console.warn and leaves (x, y) as
134
+ // the centre — good behaviour in the editor, and nothing at all here, since
135
+ // the production build strips console.warn. A typo would therefore displace
136
+ // the item by half its own size, which is the exact bug `anchor` exists to
137
+ // remove, arriving silently. Checked before it is sent instead.
138
+ const ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'center', 'centre'];
139
+ const anchorGiven = params.anchor ?? params.origin;
140
+ if (anchorGiven !== undefined) {
141
+ const normalized = String(anchorGiven).toLowerCase().replace(/[\s_]+/g, '-');
142
+ if (!ANCHORS.includes(normalized)) {
143
+ return `
144
+ // Create ${itemType} — refused, rather than silently centred
145
+ ({ success: false, error: ${JSON.stringify(`anchor ${JSON.stringify(String(anchorGiven))} is not one of ${ANCHORS.slice(0, 5).join(', ')}. `
146
+ + 'The engine would leave x/y as the CENTRE and say so only in a console this tool cannot read, '
147
+ + 'so the item would land half its own size away with nothing to explain it.')} });`.trim();
148
+ }
149
+ if (params.anchor !== undefined)
150
+ params.anchor = normalized;
151
+ if (params.origin !== undefined)
152
+ params.origin = normalized;
153
+ }
131
154
  // Build the code
132
155
  let code = `
133
156
  // Create ${itemType} item
@@ -181,12 +204,27 @@ app.historyManager.saveState();
181
204
  */
182
205
  function generateModifyItemCode(itemId, properties) {
183
206
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
207
+ // modifyItem(id, changes), NOT select() + modify().
208
+ //
209
+ // modify() operates on the SELECTION, so this selected an id and then edited
210
+ // whatever was selected — on an id that does not resolve, select changes
211
+ // nothing and modify edits the PREVIOUS selection instead. modifyItem takes
212
+ // the reference and returns false when it cannot resolve it, which is the
213
+ // answer this emitter needs and never asked for.
214
+ //
215
+ // The warning the engine logs on that path is console.warn, which production
216
+ // strips, so the failure was invisible twice over.
184
217
  let code = `
185
218
  // Modify item ${itemId}
186
- app.select('${itemId}');`;
187
- // Pass non-dynamic-content properties to app.modify
219
+ let _modified = true;`;
188
220
  if (Object.keys(restProperties).length > 0) {
189
- code += `\napp.modify(${JSON.stringify(restProperties, null, 2)});`;
221
+ code += `
222
+ _modified = app.modifyItem('${itemId}', ${JSON.stringify(restProperties, null, 2)}) !== false;
223
+ if (!_modified) { return { success: false, error: 'no item ${itemId} — nothing was modified' }; }`;
224
+ }
225
+ else {
226
+ code += `
227
+ if (!app.getItemById('${itemId}')) { return { success: false, error: 'no item ${itemId}' }; }`;
190
228
  }
191
229
  // Handle dynamic content type changes
192
230
  if (contentType !== undefined) {
@@ -212,10 +250,10 @@ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.it
212
250
  }
213
251
  code += `
214
252
  app.historyManager.saveState();
215
-
216
- // Return success
217
- ({ success: true, itemId: '${itemId}' });`;
218
- return code.trim();
253
+ return { success: true, itemId: '${itemId}' };`;
254
+ // Wrapped, because the body now RETURNS early on a miss rather than falling
255
+ // through to an unconditional success.
256
+ return `(function() {\n${code.trim()}\n})();`;
219
257
  }
220
258
  /**
221
259
  * Template for deleting items
@@ -460,8 +498,62 @@ app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
460
498
  /**
461
499
  * Template for getting items
462
500
  */
501
+ /**
502
+ * radiusX / radiusY REACH NOTHING.
503
+ *
504
+ * The engine's create() feeds a shape's config from width, height and radius.
505
+ * radiusX and radiusY are not among them, so an ellipse asked for
506
+ * `{radiusX: 250, radiusY: 72}` was built at the DEFAULT 100x60 and reported
507
+ * success — measured from production as "about 90x50". This package documented
508
+ * that exact pair as the ellipse's properties and used it in two worked
509
+ * examples, so a model following the guide got the wrong size every time and
510
+ * nothing anywhere said why.
511
+ *
512
+ * Mapped rather than merely re-documented: the radii are the natural way to
513
+ * describe an ellipse, the conversion is unambiguous (a radius is half its
514
+ * axis), and a caller who already wrote radiusX should not have to rewrite it.
515
+ * An explicit width/height wins, so nothing that works today changes.
516
+ *
517
+ * A COMPATIBILITY SHIM WITH A REMOVAL CONDITION. FxTool aaaf98ae aliases the
518
+ * same pair in the engine's shared param normaliser, which is the better place
519
+ * — it fixes every door at once, not just this one. This stays until that
520
+ * reaches the studios this package is pointed at, which is not the same moment
521
+ * it reaches origin/main: mcp-server ships independently and drives whatever
522
+ * studio it is given. The two cannot fight, because this deletes radiusX
523
+ * before the call, so the engine's alias never sees it.
524
+ *
525
+ * Delete this when a studio without aaaf98ae is no longer worth supporting.
526
+ */
527
+ function withRadiusAxes(props) {
528
+ const { radiusX, radiusY, ...rest } = props;
529
+ if (radiusX === undefined && radiusY === undefined)
530
+ return props;
531
+ const out = { ...rest };
532
+ if (typeof radiusX === 'number' && out.width === undefined)
533
+ out.width = radiusX * 2;
534
+ if (typeof radiusY === 'number' && out.height === undefined)
535
+ out.height = radiusY * 2;
536
+ return out;
537
+ }
463
538
  function generateGetItemsCode(filter) {
464
- if (!filter || Object.keys(filter).length === 0) {
539
+ // A filter OBJECT is not the same as a filter. `{}` is already handled, but
540
+ // so is `{ type: undefined }` and anything whose keys this function does not
541
+ // recognise: those have a non-zero key count and contribute no conditions,
542
+ // and the emitted `entries.filter(entry => )` is a SYNTAX ERROR rather than
543
+ // a wrong answer — the whole call dies before it runs. Decide on the
544
+ // conditions, not on the keys.
545
+ const conditions = [];
546
+ if (filter?.type)
547
+ conditions.push(`entry.type === '${filter.type}'`);
548
+ if (filter?.source)
549
+ conditions.push(`entry.source === '${filter.source}'`);
550
+ if (filter?.hasAnimation !== undefined) {
551
+ conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
552
+ }
553
+ if (filter?.hasRelation !== undefined) {
554
+ conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
555
+ }
556
+ if (conditions.length === 0) {
465
557
  return `
466
558
  // Get all items
467
559
  const entries = app.itemRegistry.getAll();
@@ -476,19 +568,6 @@ const items = entries.map(entry => ({
476
568
  ({ items, count: items.length });
477
569
  `.trim();
478
570
  }
479
- const conditions = [];
480
- if (filter.type) {
481
- conditions.push(`entry.type === '${filter.type}'`);
482
- }
483
- if (filter.source) {
484
- conditions.push(`entry.source === '${filter.source}'`);
485
- }
486
- if (filter.hasAnimation !== undefined) {
487
- conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
488
- }
489
- if (filter.hasRelation !== undefined) {
490
- conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
491
- }
492
571
  return `
493
572
  // Get items with filter
494
573
  const entries = app.itemRegistry.getAll();
@@ -675,9 +754,24 @@ function generateSetCanvasSizeCode(width, height, preset) {
675
754
  : `{ width: ${width}, height: ${height} }`;
676
755
  return `
677
756
  // Set canvas size
678
- app.setCanvasSize(${sizeArg});
679
- app.historyManager.saveState();
680
- ({ success: true, width: ${width}, height: ${height} });
757
+ (function() {
758
+ // READ THE VERDICT. This called setCanvasSize and then declared success with
759
+ // the dimensions it had ASKED for — so an unknown preset, or a width of -5,
760
+ // came back as a successful resize while the canvas was left exactly as it
761
+ // was. The engine now refuses by name; older builds return undefined, which
762
+ // is not a refusal and must not be read as one.
763
+ const r = app.setCanvasSize(${sizeArg});
764
+ if (r && r.ok === false) {
765
+ return { success: false, error: r.reason || 'the canvas size was refused',
766
+ ...(r.known ? { knownPresets: r.known } : {}) };
767
+ }
768
+ app.historyManager.saveState();
769
+ // Report what the canvas ACTUALLY took where the engine says so, rather than
770
+ // echoing the request back.
771
+ return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
772
+ ...(r && r.preset ? { preset: r.preset } : {}),
773
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {}) };
774
+ })();
681
775
  `.trim();
682
776
  }
683
777
  /**
@@ -771,15 +865,51 @@ const stats = app.getRelationStats();
771
865
  function generateListGeneratorsCode() {
772
866
  return `
773
867
  // List available generators
774
- const generators = app.getAvailableBackgroundGenerators();
775
- const formatted = generators.map(g => ({
776
- name: g.name,
777
- displayName: g.displayName || g.name,
778
- category: g.category || 'background',
779
- description: g.description || ''
780
- }));
868
+ //
869
+ // THREE OF SEVENTY-FOUR. getAvailableBackgroundGenerators() returned a
870
+ // hand-written literal of three names while the registry held 74 — nothing was
871
+ // broken, the other seventy-one executed fine BY NAME, they were simply
872
+ // invisible to anyone asking what exists. listGenerators() merges the registry
873
+ // and awaits it; it is newer than some studios, so the old call stays as the
874
+ // fallback and the result says which answer you got.
875
+ //
876
+ // Wrapped in an async IIFE deliberately: a bare top-level await parses under
877
+ // the governor and is a syntax error on the eval path.
878
+ (async function() {
879
+ let generators;
880
+ let complete = true;
881
+ if (typeof app.listGenerators === 'function') {
882
+ generators = await app.listGenerators();
883
+ } else {
884
+ generators = app.getAvailableBackgroundGenerators();
885
+ // The merged array carries a non-enumerable pending flag when the registry
886
+ // chunk has not landed, so a cold page honestly answers three.
887
+ complete = !generators || generators.pending !== true;
888
+ }
889
+ // PARAMS ARE THE WHOLE POINT OF ASKING.
890
+ //
891
+ // The tool says it lists generators "with their parameters" and this mapper
892
+ // dropped the params key, so every answer was a name and a sentence. A
893
+ // caller who wanted to know what drawGradientMesh accepts had to guess, and
894
+ // an unknown generatorParams key is accepted in silence — so a guess that
895
+ // was wrong looked exactly like one that was right.
896
+ const formatted = (generators || []).map(g => ({
897
+ name: g.name,
898
+ displayName: g.displayName || g.name,
899
+ category: g.category || 'background',
900
+ description: g.description || '',
901
+ ...(g.params ? { params: g.params } : {})
902
+ }));
781
903
 
782
- ({ generators: formatted });
904
+ return {
905
+ generators: formatted,
906
+ count: formatted.length,
907
+ ...(complete ? {} : {
908
+ partial: true,
909
+ note: 'the generator registry had not finished loading, so this is a partial list — the built-ins only. Retry for the full set.',
910
+ }),
911
+ };
912
+ })();
783
913
  `.trim();
784
914
  }
785
915
  /**
@@ -1011,6 +1141,64 @@ const ENSURE_EXPORT_ENGINE = `
1011
1141
  if (!app.exportEngine && typeof app.ensureHeavyModules === 'function') {
1012
1142
  try { await app.ensureHeavyModules(); } catch (_) { /* the guards below still speak */ }
1013
1143
  }`;
1144
+ /**
1145
+ * A REMOTE <image> INSIDE AN SVG TAINTS THE CANVAS, PERMANENTLY.
1146
+ *
1147
+ * paper.importSVG draws a cross-origin raster straight onto the canvas. The
1148
+ * canvas then has an origin-dirty flag, and every export afterwards throws
1149
+ * "Tainted canvases may not be exported" — not just the one containing the
1150
+ * image. Nothing recovers it but a page reload, so ONE import poisons the
1151
+ * whole session, long after the call that did it returned success.
1152
+ *
1153
+ * Reported from production: import_svg with a picsum href answered
1154
+ * success/itemsCreated:0 and then broke every later export.
1155
+ *
1156
+ * So each remote href is fetched and inlined as a data: URL before the SVG
1157
+ * goes anywhere near the importer — the same thing import_image does, for the
1158
+ * same reason. An href that CANNOT be inlined has its element removed: a
1159
+ * missing picture is a visible, local, recoverable problem, and leaving it in
1160
+ * is an unrecoverable one that surfaces somewhere else entirely.
1161
+ */
1162
+ const INLINE_REMOTE_IMAGES = `
1163
+ async function inlineRemoteImages(svgText) {
1164
+ const notes = [];
1165
+ const hrefs = [];
1166
+ const re = /(?:xlink:)?href\\s*=\\s*("|')(https?:\\/\\/[^"']+)\\1/gi;
1167
+ let m;
1168
+ while ((m = re.exec(svgText)) !== null) if (hrefs.indexOf(m[2]) === -1) hrefs.push(m[2]);
1169
+ if (hrefs.length === 0) return { svg: svgText, notes: notes };
1170
+ for (const href of hrefs) {
1171
+ let dataUrl = null;
1172
+ try {
1173
+ const r = await fetch(href, { mode: 'cors' });
1174
+ if (r.ok) {
1175
+ const blob = await r.blob();
1176
+ dataUrl = await new Promise(function(res, rej) {
1177
+ const fr = new FileReader();
1178
+ fr.onload = function() { res(fr.result); };
1179
+ fr.onerror = function() { rej(new Error('could not read ' + href)); };
1180
+ fr.readAsDataURL(blob);
1181
+ });
1182
+ }
1183
+ } catch (e) { /* fall through to removal */ }
1184
+ if (dataUrl) {
1185
+ svgText = svgText.split(href).join(dataUrl);
1186
+ } else {
1187
+ // Drop the element rather than let it taint the canvas.
1188
+ const esc = href.replace(/[.*+?^\\\${}()|[\\]\\\\]/g, '\\\\$&');
1189
+ svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[^>]*\\\\/?>', 'gi'), '');
1190
+ svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[\\\\s\\\\S]*?<\\\\/image>', 'gi'), '');
1191
+ notes.push({
1192
+ url: href,
1193
+ action: 'removed',
1194
+ reason: 'unfetchable',
1195
+ message: 'removed an embedded image this page could not fetch (' + href + '). It was dropped rather than imported, because a cross-origin raster taints the canvas and every later export would have failed with "Tainted canvases may not be exported" until a reload.',
1196
+ });
1197
+ }
1198
+ }
1199
+ return { svg: svgText, notes: notes };
1200
+ }
1201
+ `;
1014
1202
  export class PinePaperCodeGenerator {
1015
1203
  /**
1016
1204
  * Generate code for creating an item
@@ -1220,19 +1408,37 @@ export class PinePaperCodeGenerator {
1220
1408
  if (url) {
1221
1409
  return `
1222
1410
  // Import SVG from URL${source === 'figma' ? ' (Figma-normalised)' : ''}
1223
- const response = await fetch('${url}');
1224
- const svgText = await response.text();
1225
- const _r = ${importExpr('svgText')};
1226
- const imported = _r.item;
1227
- if (imported) {
1411
+ // WRAPPED, because these were bare top-level statements with an await. That
1412
+ // parses under the governor, which builds an AsyncFunction, and is a SYNTAX
1413
+ // ERROR on the eval fallback — so importing an SVG by URL never ran at all on
1414
+ // a studio without runGenerated. A paren-led async IIFE parses on both and
1415
+ // still gives the governor a trailing expression to capture.
1416
+ (async function() {
1417
+ let response;
1418
+ try {
1419
+ response = await fetch('${url}');
1420
+ } catch (netErr) {
1421
+ return { success: false, error: 'could not reach ${url} — ' + ((netErr && netErr.message) || 'network request failed') };
1422
+ }
1423
+ if (!response.ok) {
1424
+ return { success: false, error: 'the server refused ${url} — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
1425
+ }
1426
+ let svgText = await response.text();
1427
+ ${INLINE_REMOTE_IMAGES}
1428
+ const _inl = await inlineRemoteImages(svgText);
1429
+ svgText = _inl.svg;
1430
+ const _r = ${importExpr('svgText')};
1431
+ const imported = _r.item;
1432
+ if (!imported) {
1433
+ 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.' };
1434
+ }
1228
1435
  imported.position = new paper.Point(${position.x}, ${position.y});
1229
1436
  imported.scale(${scale});
1230
1437
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1231
1438
  app.historyManager.saveState();
1232
- ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
1233
- } else {
1234
- throw new Error('Failed to import SVG from URL');
1235
- }
1439
+ return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
1440
+ ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
1441
+ })();
1236
1442
  `.trim();
1237
1443
  }
1238
1444
  if (svgString) {
@@ -1240,18 +1446,23 @@ if (imported) {
1240
1446
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1241
1447
  return `
1242
1448
  // Import SVG string${source === 'figma' ? ' (Figma-normalised)' : ''}
1243
- const svgString = \`${escapedSvg}\`;
1449
+ (async function() {
1450
+ let svgString = \`${escapedSvg}\`;
1451
+ ${INLINE_REMOTE_IMAGES}
1452
+ const _inl = await inlineRemoteImages(svgString);
1453
+ svgString = _inl.svg;
1244
1454
  const _r = ${importExpr('svgString')};
1245
1455
  const imported = _r.item;
1246
- if (imported) {
1247
- imported.position = new paper.Point(${position.x}, ${position.y});
1248
- imported.scale(${scale});
1249
- const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1250
- app.historyManager.saveState();
1251
- ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
1252
- } else {
1456
+ if (!imported) {
1253
1457
  throw new Error('Failed to import SVG');
1254
1458
  }
1459
+ imported.position = new paper.Point(${position.x}, ${position.y});
1460
+ imported.scale(${scale});
1461
+ const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
1462
+ app.historyManager.saveState();
1463
+ return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
1464
+ ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
1465
+ })();
1255
1466
  `.trim();
1256
1467
  }
1257
1468
  return `
@@ -2173,11 +2384,28 @@ throw new Error('Unknown diagram mode action: ${action}');
2173
2384
  hasImages: false,
2174
2385
  };
2175
2386
 
2176
- // Get canvas size
2177
- if (app.canvasEl) {
2178
- analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height };
2179
- } else if (paper.view) {
2180
- analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height };
2387
+ // THE ARTBOARD, NOT THE BACKING STORE.
2388
+ //
2389
+ // canvasEl.width and paper.view.size are both in device pixels, so a
2390
+ // 1920x1080 artboard was reported as 2233x1472 — measured from production.
2391
+ // That is not even the device pixel ratio (a 2233px store in a 924px box is
2392
+ // 2.4166), so a caller cannot divide it back out. Anything derived from it
2393
+ // is wrong by an unknown factor, and synthetic clicks computed from it miss
2394
+ // in silence. getCanvasSize() is the artboard the caller set.
2395
+ if (typeof app.getCanvasSize === 'function') {
2396
+ const cs = app.getCanvasSize();
2397
+ if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
2398
+ }
2399
+ if (!analysis.canvasSize.width) {
2400
+ if (app.canvasSize && app.canvasSize.width) {
2401
+ analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
2402
+ } else if (app.canvasEl) {
2403
+ // Last resort on a studio with neither: a backing-store size is better
2404
+ // than zero, and it is flagged so nobody treats it as the artboard.
2405
+ analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
2406
+ } else if (paper.view) {
2407
+ analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
2408
+ }
2181
2409
  }
2182
2410
 
2183
2411
  // Count items and analyze
@@ -2424,7 +2652,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2424
2652
  switch (op.type) {
2425
2653
  case 'create': {
2426
2654
  const pos = op.position || { x: 400, y: 300 };
2427
- const createProps = (op.properties || {});
2655
+ const createProps = withRadiusAxes((op.properties || {}));
2428
2656
  const props = JSON.stringify(createProps);
2429
2657
  let createCode = `
2430
2658
  const item = app.create('${op.itemType}', { position: { x: ${pos.x}, y: ${pos.y} }, ...${props} });
@@ -2465,8 +2693,12 @@ return { itemId };
2465
2693
  const modifyProps = JSON.stringify(op.properties || {});
2466
2694
  return `
2467
2695
  const targetId = ${itemRef};
2468
- app.select(targetId);
2469
- app.modify(${modifyProps});
2696
+ // modifyItem(id, changes), not select() + modify(). modify() edits the
2697
+ // SELECTION, so an id that does not resolve left the PREVIOUS selection to be
2698
+ // edited instead — and this then reported modified:true either way.
2699
+ if (app.modifyItem(targetId, ${modifyProps}) === false) {
2700
+ return { success: false, itemId: targetId, error: 'no item ' + targetId + ' — nothing was modified' };
2701
+ }
2470
2702
  return { itemId: targetId, modified: true };
2471
2703
  `;
2472
2704
  case 'animate':
@@ -2541,9 +2773,16 @@ return { success: true, generator: '${genName}'${op.generatorRegion ? ', region:
2541
2773
  const h = op.height || 1080;
2542
2774
  const sizeArg = op.preset ? `'${op.preset}'` : `{ width: ${w}, height: ${h} }`;
2543
2775
  return `
2544
- app.setCanvasSize(${sizeArg});
2776
+ // Read the verdict, as the standalone tool now does: an unknown preset or an
2777
+ // out-of-range dimension is refused by name, and reporting the size we ASKED
2778
+ // for left the batch claiming a resize that never happened.
2779
+ const _cs = app.setCanvasSize(${sizeArg});
2780
+ if (_cs && _cs.ok === false) {
2781
+ return { success: false, error: _cs.reason || 'the canvas size was refused',
2782
+ ...(_cs.known ? { knownPresets: _cs.known } : {}) };
2783
+ }
2545
2784
  await new Promise(r => setTimeout(r, 50));
2546
- return { success: true, width: ${w}, height: ${h} };
2785
+ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.height) || ${h} };
2547
2786
  `;
2548
2787
  }
2549
2788
  case 'keyframe_animate': {
@@ -2741,6 +2980,19 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2741
2980
  const scaled = scale !== undefined && scale !== 1
2742
2981
  ? { width: even(preset.width * scale), height: even(preset.height * scale) }
2743
2982
  : { width: preset.width, height: preset.height };
2983
+ // 'auto' MEANS THE CANVAS, NOT A PRESET THAT HAPPENS TO BE FIRST.
2984
+ //
2985
+ // `platform` defaults to 'auto', 'auto' is not a key in platformPresets,
2986
+ // and the lookup falls through to `web` — so an export that named no
2987
+ // platform silently rendered 800x600. A beta probe measured exactly that:
2988
+ // three exports of a 960x540 canvas all came back 800x600, reported as a
2989
+ // dimension mismatch because from outside it is one. Nothing said the
2990
+ // canvas had been replaced by a preset.
2991
+ //
2992
+ // Resolved in the generated code rather than here, because the canvas size
2993
+ // is only known in the page. The preset stays the fallback for a studio
2994
+ // too old to report one, and `scale` still applies either way.
2995
+ const usesCanvasSize = !platformPresets[platform];
2744
2996
  // Resolve "auto" format to the platform's recommended format
2745
2997
  const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
2746
2998
  // AUDIO-ONLY EXPORT, off the visual path entirely.
@@ -2825,7 +3077,35 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2825
3077
  // doubled the frame count and the render time — a surprise the schema used
2826
3078
  // to hide because there was no way to ask for one without the other.
2827
3079
  const settings = ${JSON.stringify(fps !== undefined ? { ...qualitySettings, fps } : qualitySettings)};
2828
- const dimensions = ${JSON.stringify(scaled)};
3080
+ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as a fallback for a
3081
+ // studio too old to report one. Named platforms emit a plain object literal
3082
+ // just below, so what they render stays readable in the code itself.
3083
+ const __presetDims = ${JSON.stringify(scaled)};
3084
+ const __canvasDims = app.canvasSize || (app.view && app.view.viewSize) || null;
3085
+ const __even = (n) => Math.max(2, Math.round((n * ${scale !== undefined ? scale : 1}) / 2) * 2);
3086
+ const dimensions = (__canvasDims && __canvasDims.width && __canvasDims.height)
3087
+ ? { width: __even(__canvasDims.width), height: __even(__canvasDims.height) }
3088
+ : __presetDims;` : ` const dimensions = ${JSON.stringify(scaled)};`}
3089
+
3090
+ // WHAT THIS SCENE LOSES TO THIS FORMAT, asked at the moment you would care.
3091
+ //
3092
+ // exportFidelity walks the actual scene: a PNG of an animated scene is one
3093
+ // frame, a GIF with a soundtrack drops it, a wav of a silent scene is
3094
+ // silence. The engine derived it so that 117 tool descriptions would not
3095
+ // have to answer "does this survive export?" by hand and drift.
3096
+ //
3097
+ // Reported ONLY when there is something to report. An empty list would read
3098
+ // as "this format is lossless", and it does not mean that — it means this
3099
+ // scene has nothing THIS format will lose. Omitting it entirely is the one
3100
+ // shape that cannot be misread, and it keeps a clean export quiet.
3101
+ function fidelity(fmt) {
3102
+ try {
3103
+ if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') return {};
3104
+ const r = app.exportEngine.exportFidelity(fmt);
3105
+ if (!r || !r.warnings || r.warnings.length === 0) return {};
3106
+ return { fidelity: { warnings: r.warnings, checked: r.checked } };
3107
+ } catch (e) { return {}; }
3108
+ }
2829
3109
 
2830
3110
  // Preflight: same resolved settings as the real export, but render nothing.
2831
3111
  // Branching HERE rather than in a separate tool is deliberate — the estimate
@@ -2851,6 +3131,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2851
3131
  fps: settings.fps,
2852
3132
  dimensions: dimensions.width + 'x' + dimensions.height,
2853
3133
  estimate: est,
3134
+ ...fidelity(format),
2854
3135
  };
2855
3136
  }
2856
3137
 
@@ -3080,6 +3361,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3080
3361
  result = { success: false, error: e.message, platform, format };
3081
3362
  }
3082
3363
 
3364
+ // One site, after every branch has set its result: a successful export says
3365
+ // what this scene lost to this format, rather than the caller finding out by
3366
+ // opening the file. Only when there is something to say — see fidelity().
3367
+ if (result && result.success) Object.assign(result, fidelity(format));
3368
+
3083
3369
  return result;
3084
3370
  })();
3085
3371
  `.trim();
@@ -3106,11 +3392,28 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3106
3392
  hasImages: false,
3107
3393
  };
3108
3394
 
3109
- // Get canvas size
3110
- if (app.canvasEl) {
3111
- analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height };
3112
- } else if (paper.view) {
3113
- analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height };
3395
+ // THE ARTBOARD, NOT THE BACKING STORE.
3396
+ //
3397
+ // canvasEl.width and paper.view.size are both in device pixels, so a
3398
+ // 1920x1080 artboard was reported as 2233x1472 — measured from production.
3399
+ // That is not even the device pixel ratio (a 2233px store in a 924px box is
3400
+ // 2.4166), so a caller cannot divide it back out. Anything derived from it
3401
+ // is wrong by an unknown factor, and synthetic clicks computed from it miss
3402
+ // in silence. getCanvasSize() is the artboard the caller set.
3403
+ if (typeof app.getCanvasSize === 'function') {
3404
+ const cs = app.getCanvasSize();
3405
+ if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
3406
+ }
3407
+ if (!analysis.canvasSize.width) {
3408
+ if (app.canvasSize && app.canvasSize.width) {
3409
+ analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
3410
+ } else if (app.canvasEl) {
3411
+ // Last resort on a studio with neither: a backing-store size is better
3412
+ // than zero, and it is flagged so nobody treats it as the artboard.
3413
+ analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
3414
+ } else if (paper.view) {
3415
+ analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
3416
+ }
3114
3417
  }
3115
3418
 
3116
3419
  // Count items and analyze types
@@ -3317,8 +3620,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3317
3620
  */
3318
3621
  generateLoadMap(input) {
3319
3622
  const validated = LoadMapInputSchema.parse(input);
3320
- const { mapId, ...mapOptions } = validated;
3321
- const optionsStr = Object.keys(mapOptions).length > 0 ? JSON.stringify(mapOptions) : '{}';
3623
+ const { mapId, ...raw } = validated;
3624
+ // THIS SCHEMA AND loadMap() SHARED ALMOST NO VOCABULARY.
3625
+ //
3626
+ // loadMap(source, options) destructures projection, center, scale, rotate,
3627
+ // fitBounds, parent, styles, style, interactive, selectable, showOcean,
3628
+ // oceanColor, quality, smoothPaths and simplifyTolerance. Of the twelve
3629
+ // options this tool offered, SEVEN were none of those: the styling keys
3630
+ // live nested under `styles` and are spelled differently there, and the two
3631
+ // interactivity flags have other names entirely. Every one was accepted,
3632
+ // JSON-stringified into the call and dropped — a caller who set fillColor
3633
+ // got the default grey with no indication why.
3634
+ const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, ...passthrough } = raw;
3635
+ // The engine's own style vocabulary — see stylePresets in MapSystem.js.
3636
+ const styles = {};
3637
+ if (fillColor !== undefined)
3638
+ styles.fill = fillColor;
3639
+ if (strokeColor !== undefined)
3640
+ styles.stroke = strokeColor;
3641
+ // strokeWidth only survives inside `styles`; the merge overwrites a
3642
+ // top-level one with the quality preset's value.
3643
+ if (strokeWidth !== undefined)
3644
+ styles.strokeWidth = strokeWidth;
3645
+ if (hoverFill !== undefined)
3646
+ styles.hoverFill = hoverFill;
3647
+ // hoverStroke reads from mergedStyles beside hoverFill, so it belongs in
3648
+ // the same place. It landed late — the two MCP specs documented it for a
3649
+ // long time before any source file did — and an older studio merges it,
3650
+ // finds nothing that reads it, and leaves the outline at the base stroke.
3651
+ // Mapped rather than stripped so it simply starts working on such a studio
3652
+ // without a change here, and reported below so a caller on an older one
3653
+ // learns why their outline did not move.
3654
+ if (hoverStroke !== undefined)
3655
+ styles.hoverStroke = hoverStroke;
3656
+ const engineOptions = { ...passthrough };
3657
+ if (Object.keys(styles).length > 0)
3658
+ engineOptions.styles = styles;
3659
+ if (enableHover !== undefined)
3660
+ engineOptions.interactive = enableHover;
3661
+ if (enableClick !== undefined)
3662
+ engineOptions.selectable = enableClick;
3663
+ const ignored = hoverStroke !== undefined ? ['hoverStroke'] : [];
3664
+ const optionsStr = Object.keys(engineOptions).length > 0 ? JSON.stringify(engineOptions) : '{}';
3665
+ const warn = ignored.length
3666
+ ? `\n conditional: ${JSON.stringify(ignored)},\n note: 'hoverStroke needs a studio recent enough to read it; an older one ignores it and the hover outline stays the base stroke.',`
3667
+ : '';
3322
3668
  return `
3323
3669
  // Load geographic map
3324
3670
  (async function() {
@@ -3333,7 +3679,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3333
3679
  mapId: result.mapId || '${mapId}',
3334
3680
  regions: result.regions?.length || 0,
3335
3681
  bounds: result.bounds,
3336
- center: result.center
3682
+ center: result.center,${warn}
3337
3683
  };
3338
3684
  } catch (error) {
3339
3685
  return { success: false, error: error.message };
@@ -4071,7 +4417,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4071
4417
  // records the baked offset at item.data._bakedRotation and sums the two itself
4072
4418
  // (PinePaper.js does exactly this); read the same sum rather than the half of
4073
4419
  // it that happens to be zero.
4074
- const _rot = (item.data?._bakedRotation || 0) + (item.rotation || 0);
4420
+ const _rot = typeof app.authoredRotation === 'function' ? app.authoredRotation(item) : ((item.data?._bakedRotation || 0) + (item.rotation || 0));
4075
4421
  if (_rot) itemData.properties.rotation = _rot;
4076
4422
  if (item.data?.content) itemData.properties.content = item.data.content;
4077
4423
  if (item.visible === false) itemData.properties.visible = false;
@@ -4433,6 +4779,33 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4433
4779
  }
4434
4780
  })();
4435
4781
  `.trim();
4782
+ }
4783
+ /**
4784
+ * What THIS item can animate, from the engine's own table.
4785
+ *
4786
+ * The table is derived from the applier rather than written beside it, so it
4787
+ * cannot drift from what actually gets applied — and it is filtered by item
4788
+ * type, because the trim/dash four only exist on a path and a rectangle has
4789
+ * no fontSize.
4790
+ *
4791
+ * `interpolates: false` is passed through VERBATIM and never flattened away.
4792
+ * `content` and `blendMode` are DISCRETE — they hold their value until the
4793
+ * next keyframe rather than blending toward it — and treating content as
4794
+ * tweenable is what made every word in a cycler appear half a beat early.
4795
+ * A list that says only "animatable" rebuilds that bug in the next agent.
4796
+ */
4797
+ generateListItemAnimatableProperties(itemId) {
4798
+ return `
4799
+ // Animatable properties of ${JSON.stringify(itemId)}
4800
+ (function() {
4801
+ if (typeof app.listAnimatableProperties !== 'function') {
4802
+ return { success: false, error: 'app.listAnimatableProperties unavailable — update the studio. The keyframe tool lists the properties it accepts in its own description meanwhile.' };
4803
+ }
4804
+ const item = app.getItemById(${JSON.stringify(itemId)});
4805
+ if (!item) { return { success: false, error: 'no item ${itemId}' }; }
4806
+ const r = app.listAnimatableProperties(item);
4807
+ return { success: true, ...r };
4808
+ })();`.trim();
4436
4809
  }
4437
4810
  generateGetAnimatableProperties() {
4438
4811
  return `
@@ -4559,7 +4932,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4559
4932
  }
4560
4933
  try {
4561
4934
  await app.templateManager.loadTemplate('${templateId}', true);
4562
- return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.' };
4935
+
4936
+ // DID THE CLIPPED CHARACTER PARTS SURVIVE?
4937
+ //
4938
+ // A clipped character group is saved with its nested texture and mask in
4939
+ // paperJSON. A studio whose restore predates the reader for that key
4940
+ // rebuilds it through the generic group branch instead — an empty,
4941
+ // unclipped group — so the part is written to the template correctly and
4942
+ // dropped on the way back in, with nothing raised.
4943
+ //
4944
+ // Checked by BEHAVIOUR, not by version: the template says how many clipped
4945
+ // groups it carries, the canvas says how many came back. An engine that
4946
+ // restores them makes this condition unreachable, so it needs no removing
4947
+ // later; an older studio is exactly where it still matters, and this
4948
+ // package ships independently of the studio it drives.
4949
+ //
4950
+ // THE WARNING MATTERS MORE THAN THE LOAD. Loading is harmless and
4951
+ // reversible — the file on disk still holds the paperJSON, so the same
4952
+ // template reloads intact on a studio that can read it. Re-SAVING from
4953
+ // this canvas is not: the exporter would serialise the empty group it can
4954
+ // see, and the texture and mask would leave the file for good.
4955
+ let fidelity = {};
4956
+ try {
4957
+ const tpl = (app.templateManager.getAllTemplates() || [])
4958
+ .find(function(t) { return t && (t.id === '${templateId}' || t.templateId === '${templateId}'); });
4959
+ const items = tpl && tpl.data && Array.isArray(tpl.data.items) ? tpl.data.items : null;
4960
+ const declared = items ? items.filter(function(i) { return i && i.type === 'group' && i.paperJSON; }).length : 0;
4961
+ if (declared > 0 && app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
4962
+ const clipped = (app.itemRegistry.getAll() || []).filter(function(e) {
4963
+ const it = e && (e.item || e);
4964
+ return it && it.className === 'Group' && it.clipped === true;
4965
+ }).length;
4966
+ if (clipped === 0) {
4967
+ fidelity = {
4968
+ clippedPartsLost: declared,
4969
+ warning: 'This template carries ' + declared + ' clipped character part(s) with a nested texture and mask, '
4970
+ + 'and this studio restored none of them — they are on the canvas as empty groups. '
4971
+ + 'DO NOT SAVE THIS CANVAS BACK OVER THE TEMPLATE: the export would write the empty groups and the '
4972
+ + 'texture would be lost from the file permanently. The template itself is still intact; update the '
4973
+ + 'studio and load it again.',
4974
+ };
4975
+ }
4976
+ }
4977
+ } catch (e) { /* a check that cannot run must not fail the load */ }
4978
+
4979
+ return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.', ...fidelity };
4563
4980
  } catch (e) {
4564
4981
  return { error: 'Failed to apply template: ' + e.message };
4565
4982
  }
@@ -4605,7 +5022,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4605
5022
  res = await fetch(src);
4606
5023
  } catch (netErr) {
4607
5024
  return { success: false, error: 'could not reach ' + src + ' — ' + (netErr && netErr.message ? netErr.message : 'network request failed')
4608
- + '. 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.' };
5025
+ + '. The failure is in the browser network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
4609
5026
  }
4610
5027
  if (!res.ok) {
4611
5028
  return { success: false, error: 'the server refused ' + src + ' — HTTP ' + res.status + ' ' + (res.statusText || ''), status: res.status };
@@ -4829,7 +5246,21 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
4829
5246
  // Toggle loop mode
4830
5247
  (function() {
4831
5248
  ${guard}
4832
- app.sceneManager.setLoop(${input.enabled ?? true});
5249
+ // chainLoop is a PROPERTY, set by createChain({loop}) and read by the chain
5250
+ // advance. setLoop has never existed, so this reported a toggle it had not
5251
+ // performed — and a chain built without loop went on not looping.
5252
+ if (typeof app.sceneManager.setLoop === 'function') {
5253
+ app.sceneManager.setLoop(${input.enabled ?? true});
5254
+ } else if ('chainLoop' in app.sceneManager) {
5255
+ app.sceneManager.chainLoop = ${input.enabled ?? true};
5256
+ } else {
5257
+ return { success: false, action: 'toggle_loop',
5258
+ 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.' };
5259
+ }
5260
+ if (!app.sceneManager.sceneChain || !app.sceneManager.sceneChain.length) {
5261
+ return { success: true, action: 'toggle_loop', enabled: ${input.enabled ?? true},
5262
+ note: 'no chain exists yet — this setting applies to the next one created, and create_chain takes loop directly.' };
5263
+ }
4833
5264
  return { success: true, action: 'toggle_loop', enabled: ${input.enabled ?? true} };
4834
5265
  })();
4835
5266
  `.trim();
@@ -5707,7 +6138,7 @@ ${resolve}
5707
6138
  bounds: item.bounds ? { x: item.bounds.x, y: item.bounds.y, width: item.bounds.width, height: item.bounds.height } : null,
5708
6139
  visible: item.visible,
5709
6140
  opacity: item.opacity,
5710
- rotation: (item.data?._bakedRotation || 0) + (item.rotation || 0),
6141
+ rotation: typeof app.authoredRotation === 'function' ? app.authoredRotation(item) : ((item.data?._bakedRotation || 0) + (item.rotation || 0)),
5711
6142
  selected: item.selected,
5712
6143
  };
5713
6144
  })();`.trim();
@@ -6481,7 +6912,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6481
6912
  const entry = app.itemRegistry.get(${itemIdStr});
6482
6913
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
6483
6914
  const b = entry.item.bounds;
6484
- return { success: true, action: 'get_dimensions', itemId: ${itemIdStr}, x: b.x, y: b.y, width: b.width, height: b.height, rotation: (entry.item.data?._bakedRotation || 0) + (entry.item.rotation || 0) };
6915
+ 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)) };
6485
6916
  })();`.trim();
6486
6917
  }
6487
6918
  case 'set_snap': {
@@ -6990,10 +7421,25 @@ ${guard}
6990
7421
  });
6991
7422
  return `
6992
7423
  // Display text style: ${input.styleKey} — stacked-layer title, id adopted from the text
6993
- (function() {
7424
+ (async function() {
6994
7425
  if (typeof app.applyTextStyle !== 'function') { return { success: false, error: 'app.applyTextStyle unavailable — update FxTool to a text-styles build' }; }
7426
+ // LOAD THE FACES FIRST. The decorative stylesheet is fetched on pointerenter
7427
+ // over the style grid — a gesture no agent ever makes — so every decorative
7428
+ // family was missing in production and a style asking for one silently drew
7429
+ // the fallback and reported success. ensureFontsLoaded is the fix; guarded,
7430
+ // because a studio without it behaves as it always did.
7431
+ if (typeof app.ensureFontsLoaded === 'function') {
7432
+ try { await app.ensureFontsLoaded('decorative'); } catch (_) { /* the pending flag below still reports it */ }
7433
+ }
6995
7434
  const r = app.applyTextStyle(${S(input.itemId)}, ${S(input.styleKey)}, ${opts});
6996
7435
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'style failed' }; }
7436
+ // fontPending: the face is not there YET, so this drew in the fallback. Said
7437
+ // out loud rather than left to be discovered in the export.
7438
+ if (r.fontPending) {
7439
+ return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers,
7440
+ palette: r.palette, bounds: r.bounds, fontPending: true,
7441
+ 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.' };
7442
+ }
6997
7443
  // The styled group ADOPTS the text item's registry id — r.id is the SAME id
6998
7444
  // the caller passed, so their handle keeps working. Say so in the result.
6999
7445
  return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers, palette: r.palette, bounds: r.bounds };
@@ -7824,6 +8270,40 @@ ${needWorld}
7824
8270
  for (const [k, v] of Object.entries(rest))
7825
8271
  if (v !== undefined)
7826
8272
  opts[k] = v;
8273
+ // SPREAD-THROUGH IS NOT THE SAME AS AGREEING.
8274
+ //
8275
+ // Everything above this line reaches app.stickFigure verbatim, which made
8276
+ // the schema look consumed to every guard we have while four fields meant
8277
+ // something the engine does not. The engine compares propSide against
8278
+ // 'L' | 'R', so 'right' — which the schema declared and advertised —
8279
+ // matched neither arm of that test and every prop went to the LEFT hand.
8280
+ // Nothing failed; the figure just held it in the wrong hand, forever.
8281
+ if (action === 'figure') {
8282
+ const side = typeof opts.propSide === 'string' ? opts.propSide : undefined;
8283
+ if (side)
8284
+ opts.propSide = side.toUpperCase().startsWith('R') ? 'R' : 'L';
8285
+ // The engine iterates opts.walk, so `true` — the form the schema itself
8286
+ // advertised as "the default" — walked exactly nowhere.
8287
+ if (opts.walk === true)
8288
+ opts.walk = [{ seconds: 1.2, from: 0 }];
8289
+ else if (opts.walk === false)
8290
+ delete opts.walk;
8291
+ // `facing` is read for chairs and for nothing else. Passing it here
8292
+ // would be accepted and ignored, which is the thing being fixed, so it
8293
+ // is refused by name instead — the add_ports precedent.
8294
+ if (opts.facing !== undefined) {
8295
+ // Built through S() rather than interpolated: the message names two
8296
+ // quoted values, and hand-escaping them into a single-quoted emitted
8297
+ // string produced a syntax error the first time.
8298
+ const msg = `facing is a property of a chair, not of a figure — app.stickFigure `
8299
+ + `never reads it, so ${String(opts.facing)} would have been accepted and ignored. `
8300
+ + `A figure faces the way it travels, so give it travel, or a pose that turns it. `
8301
+ + `facing belongs on action "set" with kind "chair".`;
8302
+ return `
8303
+ // Stick: figure — refused, rather than accepted and ignored
8304
+ ({ success: false, error: ${S(msg)} });`.trim();
8305
+ }
8306
+ }
7827
8307
  const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
7828
8308
  return `
7829
8309
  // Stick: ${action}