@pinepaper.studio/mcp-server 1.6.24 → 1.6.25

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 (158) hide show
  1. package/README.md +130 -14
  2. package/dist/browser/puppeteer-controller.d.ts +6 -0
  3. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  4. package/dist/browser/puppeteer-controller.js +97 -69
  5. package/dist/browser/puppeteer-controller.js.map +1 -1
  6. package/dist/build-info.json +2 -2
  7. package/dist/cli.js +7 -5
  8. package/dist/cli.js.map +1 -1
  9. package/dist/design/design-systems.js +2 -2
  10. package/dist/design/design-systems.js.map +1 -1
  11. package/dist/i18n/locales/en.js +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +51 -11
  15. package/dist/index.js.map +1 -1
  16. package/dist/mcp/index.d.ts +5 -0
  17. package/dist/mcp/index.d.ts.map +1 -0
  18. package/dist/mcp/index.js +5 -0
  19. package/dist/mcp/index.js.map +1 -0
  20. package/dist/mcp/server.d.ts +86 -0
  21. package/dist/mcp/server.d.ts.map +1 -0
  22. package/dist/mcp/server.js +159 -0
  23. package/dist/mcp/server.js.map +1 -0
  24. package/dist/mcp/stdio.d.ts +20 -0
  25. package/dist/mcp/stdio.d.ts.map +1 -0
  26. package/dist/mcp/stdio.js +82 -0
  27. package/dist/mcp/stdio.js.map +1 -0
  28. package/dist/mcp/types.d.ts +598 -0
  29. package/dist/mcp/types.d.ts.map +1 -0
  30. package/dist/mcp/types.js +140 -0
  31. package/dist/mcp/types.js.map +1 -0
  32. package/dist/metrics/performance-tracker.d.ts.map +1 -1
  33. package/dist/metrics/performance-tracker.js +4 -3
  34. package/dist/metrics/performance-tracker.js.map +1 -1
  35. package/dist/ontology/hints.d.ts.map +1 -1
  36. package/dist/ontology/hints.js +3 -1
  37. package/dist/ontology/hints.js.map +1 -1
  38. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  39. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -1
  40. package/dist/ontology/upstream-vocabulary.js +8 -2
  41. package/dist/ontology/upstream-vocabulary.js.map +1 -1
  42. package/dist/server/index.d.ts +1 -1
  43. package/dist/server/index.d.ts.map +1 -1
  44. package/dist/server/index.js +1 -1
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/tools/batch-macros.d.ts.map +1 -1
  47. package/dist/tools/batch-macros.js +2 -1
  48. package/dist/tools/batch-macros.js.map +1 -1
  49. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  50. package/dist/tools/compact-descriptions.js +7 -6
  51. package/dist/tools/compact-descriptions.js.map +1 -1
  52. package/dist/tools/craft-tools.d.ts +1 -1
  53. package/dist/tools/craft-tools.d.ts.map +1 -1
  54. package/dist/tools/create-known-keys.d.ts +2 -0
  55. package/dist/tools/create-known-keys.d.ts.map +1 -0
  56. package/dist/tools/create-known-keys.js +24 -0
  57. package/dist/tools/create-known-keys.js.map +1 -0
  58. package/dist/tools/definitions.d.ts +3 -1
  59. package/dist/tools/definitions.d.ts.map +1 -1
  60. package/dist/tools/definitions.js +43 -22
  61. package/dist/tools/definitions.js.map +1 -1
  62. package/dist/tools/effect-check.d.ts +1 -1
  63. package/dist/tools/effect-check.d.ts.map +1 -1
  64. package/dist/tools/effect-check.js +4 -0
  65. package/dist/tools/effect-check.js.map +1 -1
  66. package/dist/tools/engine-lists.d.ts +4 -1
  67. package/dist/tools/engine-lists.d.ts.map +1 -1
  68. package/dist/tools/engine-lists.js +9 -1
  69. package/dist/tools/engine-lists.js.map +1 -1
  70. package/dist/tools/engine-methods.d.ts.map +1 -1
  71. package/dist/tools/engine-methods.js +1 -0
  72. package/dist/tools/engine-methods.js.map +1 -1
  73. package/dist/tools/engine-surface.d.ts +1 -1
  74. package/dist/tools/engine-surface.d.ts.map +1 -1
  75. package/dist/tools/engine-surface.js +18 -3
  76. package/dist/tools/engine-surface.js.map +1 -1
  77. package/dist/tools/export-file-receipt.d.ts +12 -0
  78. package/dist/tools/export-file-receipt.d.ts.map +1 -1
  79. package/dist/tools/export-file-receipt.js +21 -1
  80. package/dist/tools/export-file-receipt.js.map +1 -1
  81. package/dist/tools/handlers/beats.d.ts +1 -1
  82. package/dist/tools/handlers/beats.d.ts.map +1 -1
  83. package/dist/tools/handlers/camera.d.ts +1 -1
  84. package/dist/tools/handlers/camera.d.ts.map +1 -1
  85. package/dist/tools/handlers/export.d.ts +1 -1
  86. package/dist/tools/handlers/export.d.ts.map +1 -1
  87. package/dist/tools/handlers/export.js +3 -2
  88. package/dist/tools/handlers/export.js.map +1 -1
  89. package/dist/tools/handlers/font.d.ts +1 -1
  90. package/dist/tools/handlers/font.d.ts.map +1 -1
  91. package/dist/tools/handlers/font.js +8 -7
  92. package/dist/tools/handlers/font.js.map +1 -1
  93. package/dist/tools/handlers/generate.d.ts +1 -1
  94. package/dist/tools/handlers/generate.d.ts.map +1 -1
  95. package/dist/tools/handlers/generate.js +4 -3
  96. package/dist/tools/handlers/generate.js.map +1 -1
  97. package/dist/tools/handlers/maps.d.ts +1 -1
  98. package/dist/tools/handlers/maps.d.ts.map +1 -1
  99. package/dist/tools/handlers/ontology.d.ts +1 -1
  100. package/dist/tools/handlers/ontology.d.ts.map +1 -1
  101. package/dist/tools/handlers/tool-guide.d.ts +1 -1
  102. package/dist/tools/handlers/tool-guide.d.ts.map +1 -1
  103. package/dist/tools/handlers.d.ts +15 -3
  104. package/dist/tools/handlers.d.ts.map +1 -1
  105. package/dist/tools/handlers.js +71 -31
  106. package/dist/tools/handlers.js.map +1 -1
  107. package/dist/tools/lane.d.ts +11 -6
  108. package/dist/tools/lane.d.ts.map +1 -1
  109. package/dist/tools/lane.js +46 -9
  110. package/dist/tools/lane.js.map +1 -1
  111. package/dist/tools/minimal-descriptions.js +1 -1
  112. package/dist/tools/minimal-descriptions.js.map +1 -1
  113. package/dist/tools/p5-compat/p5-helpers.d.ts +1 -1
  114. package/dist/tools/p5-compat/p5-helpers.js +1 -1
  115. package/dist/tools/shape-params.d.ts +2 -2
  116. package/dist/tools/shape-params.d.ts.map +1 -1
  117. package/dist/tools/shape-params.js +8 -2
  118. package/dist/tools/shape-params.js.map +1 -1
  119. package/dist/tools/stick-vocabulary.d.ts +2 -2
  120. package/dist/tools/stick-vocabulary.js +2 -2
  121. package/dist/tools/timeline.d.ts +2 -2
  122. package/dist/tools/timeline.d.ts.map +1 -1
  123. package/dist/tools/timeline.js +6 -1
  124. package/dist/tools/timeline.js.map +1 -1
  125. package/dist/tools/toolkits.d.ts +22 -7
  126. package/dist/tools/toolkits.d.ts.map +1 -1
  127. package/dist/tools/toolkits.js +102 -20
  128. package/dist/tools/toolkits.js.map +1 -1
  129. package/dist/types/code-generator.d.ts +25 -0
  130. package/dist/types/code-generator.d.ts.map +1 -1
  131. package/dist/types/code-generator.js +402 -157
  132. package/dist/types/code-generator.js.map +1 -1
  133. package/dist/types/schemas.d.ts +52 -47
  134. package/dist/types/schemas.d.ts.map +1 -1
  135. package/dist/types/schemas.js +7 -5
  136. package/dist/types/schemas.js.map +1 -1
  137. package/dist/utils/asset-paths.d.ts.map +1 -1
  138. package/dist/utils/asset-paths.js +4 -4
  139. package/dist/utils/asset-paths.js.map +1 -1
  140. package/dist/utils/env.d.ts +9 -0
  141. package/dist/utils/env.d.ts.map +1 -0
  142. package/dist/utils/env.js +11 -0
  143. package/dist/utils/env.js.map +1 -0
  144. package/dist/utils/export-dir.d.ts +36 -0
  145. package/dist/utils/export-dir.d.ts.map +1 -0
  146. package/dist/utils/export-dir.js +86 -0
  147. package/dist/utils/export-dir.js.map +1 -0
  148. package/dist/utils/js-literal.d.ts +4 -0
  149. package/dist/utils/js-literal.d.ts.map +1 -0
  150. package/dist/utils/js-literal.js +25 -0
  151. package/dist/utils/js-literal.js.map +1 -0
  152. package/dist/utils/mcp-log.d.ts +1 -1
  153. package/dist/utils/mcp-log.d.ts.map +1 -1
  154. package/dist/version.d.ts.map +1 -1
  155. package/dist/version.js +5 -12
  156. package/dist/version.js.map +1 -1
  157. package/manifest.json +42 -63
  158. package/package.json +8 -6
@@ -7,9 +7,11 @@
7
7
  import { compileChoreography } from '../tools/choreograph.js';
8
8
  import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
9
9
  import { normalizeSvgSize } from '../utils/svg-size.js';
10
- import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
10
+ import { WORLD3D_COLOR_PATHS, PATTERN_KINDS } from '../tools/engine-lists.js';
11
11
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
12
- import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
12
+ import { NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
13
+ import { CREATE_KNOWN_KEYS } from '../tools/create-known-keys.js';
14
+ import { jsLiteral } from '../utils/js-literal.js';
13
15
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
14
16
  import { expandBatchMacros } from '../tools/batch-macros.js';
15
17
  import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
@@ -146,7 +148,16 @@ function generateBlendModeCode(itemVarName, blendMode) {
146
148
  /**
147
149
  * Template for creating items with support for gradients, shadows, and blend modes
148
150
  */
151
+ const UNSAFE_ID_CHARS = /['"`\\\n\r\u2028\u2029]/;
149
152
  function generateCreateItemCode(itemType, position, properties, dataFlags, positionGiven = true) {
153
+ // AN ID OF YOUR OWN IS SPLICED INTO LATER CALLS' CODE. Most tools now quote it,
154
+ // not all yet, so an id carrying a quote, a backslash or a line break would
155
+ // break those later calls: refused here, by name, before anything is made.
156
+ if (typeof properties.id === 'string' && UNSAFE_ID_CHARS.test(properties.id)) {
157
+ return `
158
+ // Create ${itemType} — refused: an id later calls could not address
159
+ ({ success: false, error: ${jsLiteral(`properties.id ${JSON.stringify(properties.id)} has a quote, backslash or line break, so later calls could not address the item. Nothing was made. Use letters, digits, spaces, - and _ (e.g. "bobs-hat").`)} });`.trim();
160
+ }
150
161
  // Colour objects are gradient specs: normalised, or refused by name.
151
162
  for (const key of ['color', 'fillColor', 'strokeColor']) {
152
163
  const v = properties[key];
@@ -213,6 +224,10 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
213
224
  // All three now mean no fill, applied after the item exists.
214
225
  const noFill = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
215
226
  const clearFill = (fillColor !== undefined && noFill(fillColor)) || (fillColor === undefined && color !== undefined && noFill(color));
227
+ // A STROKE WITH NO FILL IS AN OUTLINE (issue #29 O7). A closed shape given
228
+ // only a strokeColor took the engine's default fill, so a ring came out as a
229
+ // blue disc with a red rim. Naming a stroke and no fill now means no fill.
230
+ const outlineOnly = isStrokeOnly(itemType, properties) && !clearFill;
216
231
  // `color` ON A CLOSED PATH IS ITS FILL.
217
232
  //
218
233
  // The engine's path branch paints `color` as the STROKE whenever no fillColor
@@ -337,7 +352,7 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
337
352
  if (blendMode) {
338
353
  code += '\n' + generateBlendModeCode('item', blendMode);
339
354
  }
340
- if (clearFill)
355
+ if (clearFill || outlineOnly)
341
356
  code += `\nitem.fillColor = null;`;
342
357
  // DASHES, CAPS AND JOINS FOR EVERY SHAPE (round 7 Z, 1.54). create() applies
343
358
  // them only in its path / line / pattern branches; the shared tail sets a
@@ -401,15 +416,27 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
401
416
  code += `\nif (app.setDynamicContent) app.setDynamicContent(item, '${contentType}', ${JSON.stringify(opts)});`;
402
417
  }
403
418
  const loadsFont = itemType === 'text' && typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
419
+ // A LINE WITH `from` IS DRAWN WHERE from AND to SAY (issue #29 O8). They are
420
+ // canvas coordinates, so position does not move it, and the result reported
421
+ // the position asked for while the line sat at the top-left: a line from
422
+ // [0, 0] to [200, 0] "created at (400, 300)". The result reads where it is.
423
+ const lineIgnoresPosition = itemType === 'line' && properties.from !== undefined && positionGiven;
424
+ const reportActualPosition = omitXY || lineIgnoresPosition;
425
+ const warnings = [];
426
+ if (ignored.length > 0) {
427
+ warnings.push(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
428
+ + 'Check the spelling against this item type\'s documented properties.');
429
+ }
430
+ if (lineIgnoresPosition) {
431
+ warnings.push('a line runs from `from` to `to` in canvas coordinates, so position did not move it: position is where the line is now. Give the endpoints where the line goes.');
432
+ }
404
433
  code += `
405
434
  const itemId = item.data.registryId;
406
435
  app.historyManager.saveState();
407
436
 
408
437
  // Return item info
409
- ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
410
- ? `, ignoredProperties: ${JSON.stringify(ignored)}, warning: ${JSON.stringify(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
411
- + 'Check the spelling against this item type\'s documented properties.')}`
412
- : ''} });`;
438
+ ({ itemId, type: '${itemType}'${outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : ''}${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
439
+ ? `, ignoredProperties: ${JSON.stringify(ignored)}` : ''}${warnings.length > 0 ? `, warning: ${JSON.stringify(warnings.join(' '))}` : ''} });`;
413
440
  // Loading a font is async, and it has to happen BEFORE create() measures the
414
441
  // text — so only this case becomes an async IIFE; every other create stays a
415
442
  // plain snippet ending in its (-led result.
@@ -437,24 +464,6 @@ const MODIFY_DID_YOU_MEAN = {
437
464
  scaling: 'scale', size: 'width / height', rotate: 'rotation', angle: 'rotation', shear: 'skewX / skewY', skew: 'skewX / skewY',
438
465
  alpha: 'opacity', text: 'content', stroke_width: 'strokeWidth', lineWidth: 'strokeWidth',
439
466
  };
440
- /**
441
- * Every `properties` key create_item can act on: what the engine's create()
442
- * and normalizeParams() read (generated — see shape-params.ts), plus the keys
443
- * generateCreateItemCode consumes itself before the engine sees them.
444
- */
445
- const CREATE_KNOWN_KEYS = new Set([
446
- ...ACCEPTED_CREATE_PARAMS,
447
- ...NORMALIZE_PARAM_READS,
448
- // withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
449
- // setDynamicContent for text.
450
- 'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
451
- // Lifetimes, on ANY item: this generator applies them through app.setLifetime
452
- // (emitLifetime), whatever create() itself reads.
453
- 'bornAt', 'ttl',
454
- // A solid fill's own alpha (FxTool 12 STROKEFILL); confirmed per call by
455
- // emitFillOpacityCheck, since an older studio ignores it.
456
- 'fillOpacity',
457
- ]);
458
467
  /**
459
468
  * Load a font family before text is drawn with it, and say if it still is not there.
460
469
  *
@@ -475,11 +484,11 @@ export function emitEnsureFont(family) {
475
484
  return `
476
485
  let __font = null;
477
486
  if (typeof app.checkFont === 'function') {
478
- const __v0 = app.checkFont(${JSON.stringify(family)});
487
+ const __v0 = app.checkFont(${jsLiteral(family)});
479
488
  if (__v0 && __v0.available === false) {
480
489
  if (typeof app.ensureFontsLoaded === 'function') { try { await app.ensureFontsLoaded('all'); } catch (_) { /* reported below */ } }
481
- try { if (typeof document !== 'undefined' && document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
482
- let __v1 = app.checkFont(${JSON.stringify(family)});
490
+ try { if (typeof document !== 'undefined' && document.fonts) await document.fonts.load('16px ' + ${jsLiteral(jsLiteral(primary))}); } catch (_) { /* reported below */ }
491
+ let __v1 = app.checkFont(${jsLiteral(family)});
483
492
  let __via = 'studio';
484
493
  // Not in the studio's own sheets (Monoton, a common neon face, is not):
485
494
  // the page's CSP allows fonts.googleapis.com stylesheets and
@@ -487,21 +496,21 @@ if (typeof app.checkFont === 'function') {
487
496
  if (!(__v1 && __v1.available === true) && typeof document !== 'undefined' && document.head) {
488
497
  __via = 'google-fonts';
489
498
  await new Promise(function(res) {
490
- const id = 'pp-gf-' + ${JSON.stringify(primary)}.replace(/[^a-z0-9]+/gi, '-');
499
+ const id = 'pp-gf-' + ${jsLiteral(primary)}.replace(/[^a-z0-9]+/gi, '-');
491
500
  if (document.getElementById(id)) return res();
492
501
  const l = document.createElement('link');
493
502
  l.id = id; l.rel = 'stylesheet';
494
- l.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(${JSON.stringify(primary)}).replace(/%20/g, '+') + '&display=swap';
503
+ l.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(${jsLiteral(primary)}).replace(/%20/g, '+') + '&display=swap';
495
504
  l.onload = res; l.onerror = res; setTimeout(res, 5000);
496
505
  document.head.appendChild(l);
497
506
  });
498
- try { if (document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
499
- __v1 = app.checkFont(${JSON.stringify(family)});
507
+ try { if (document.fonts) await document.fonts.load('16px ' + ${jsLiteral(jsLiteral(primary))}); } catch (_) { /* reported below */ }
508
+ __v1 = app.checkFont(${jsLiteral(family)});
500
509
  }
501
510
  __font = (__v1 && __v1.available === true)
502
- ? { family: ${JSON.stringify(primary)}, available: true, loadedNow: true, via: __via }
503
- : { family: ${JSON.stringify(primary)}, available: false,
504
- warning: ${JSON.stringify(primary)} + ' could not be loaded, so this text is drawn in a fallback face. '
511
+ ? { family: ${jsLiteral(primary)}, available: true, loadedNow: true, via: __via }
512
+ : { family: ${jsLiteral(primary)}, available: false,
513
+ warning: ${jsLiteral(primary)} + ' could not be loaded, so this text is drawn in a fallback face. '
505
514
  + ((__v1 && __v1.reason) || '') + ' font list_available names the families this studio can load.' };
506
515
  }
507
516
  }`;
@@ -572,13 +581,13 @@ function takeDirection(props) {
572
581
  function emitDirectionCheck(itemExpr, req) {
573
582
  if ('error' in req)
574
583
  return `
575
- const __direction = ${JSON.stringify({ applied: false, error: req.error })};`;
576
- const v = JSON.stringify(req.value);
584
+ const __direction = ${jsLiteral({ applied: false, error: req.error })};`;
585
+ const v = jsLiteral(req.value);
577
586
  return `
578
587
  const __direction = (function(it) {
579
588
  if (!it || it.className !== 'PointText') return { applied: false, note: 'direction applies to text items only.' };
580
589
  return it.data && it.data.direction === ${v}
581
- ? { applied: true, value: ${v}${req.resolved ? `, resolved: ${JSON.stringify(req.resolved)}` : ''} }
590
+ ? { applied: true, value: ${v}${req.resolved ? `, resolved: ${jsLiteral(req.resolved)}` : ''} }
582
591
  : { applied: false, note: 'this studio does not set a paragraph direction (it needs an engine with RTL text support): the text is laid out left-to-right.' };
583
592
  })(${itemExpr});`;
584
593
  }
@@ -678,7 +687,7 @@ function takeStrokePosition(props, allowNull) {
678
687
  }
679
688
  function emitStrokePositionCheck(itemExpr, req) {
680
689
  if ('error' in req)
681
- return `\nconst __strokePos = ${JSON.stringify({ applied: false, error: req.error })};`;
690
+ return `\nconst __strokePos = ${jsLiteral({ applied: false, error: req.error })};`;
682
691
  if (req.value === 'center')
683
692
  return `\nconst __strokePos = { applied: true, value: 'center' };`;
684
693
  return `
@@ -696,7 +705,7 @@ function anchorHold(anchor) {
696
705
  function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
697
706
  return `
698
707
  if (typeof app.fitText === 'function') {
699
- const __fbox = ${JSON.stringify(box)};
708
+ const __fbox = ${jsLiteral(box)};
700
709
  if (__fbox.hold === undefined) { const __h = ${holdFallback}; if (__h) __fbox.hold = __h; }
701
710
  const __fr = app.fitText(${itemExpr}, __fbox);
702
711
  __textFit = __fr && __fr.ok !== false
@@ -765,11 +774,11 @@ function emitTextStyle(itemExpr, props) {
765
774
  const edge = function(b) { return j === 'left' ? b.left : j === 'right' ? b.right : b.center.x; };
766
775
  const b0 = it.bounds, ax = edge(b0), ay = b0.center.y;
767
776
  ${weight !== undefined || props.fontStyle !== undefined
768
- ? `it.fontWeight = ${JSON.stringify(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
777
+ ? `it.fontWeight = ${jsLiteral(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
769
778
  : ''}
770
779
  ${leading !== undefined ? `it.leading = ${leading};` : ''}
771
780
  ${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
772
- ${ls !== undefined ? `it.letterSpacing = ${JSON.stringify(ls)};` : ''}
781
+ ${ls !== undefined ? `it.letterSpacing = ${jsLiteral(ls)};` : ''}
773
782
  const b1 = it.bounds;
774
783
  it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
775
784
  })(${itemExpr});`;
@@ -918,6 +927,9 @@ function emitLifetime(itemExpr, bornAt, ttl) {
918
927
  * Template for modifying items
919
928
  */
920
929
  function generateModifyItemCode(itemId, properties, dataFlags) {
930
+ // The id as a quoted literal everywhere below: an id is caller text ("Bob's hat" is a valid properties.id), and a
931
+ // quote spliced raw ended the string and broke the whole call.
932
+ const ID = jsLiteral(itemId);
921
933
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
922
934
  // Aliases for text items: text -> content (B66), textAlign / align -> justification (B89).
923
935
  // A key consumed here was applied, so the unread-key report below must not
@@ -961,23 +973,23 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
961
973
  // The warning the engine logs on that path is console.warn, which production
962
974
  // strips, so the failure was invisible twice over.
963
975
  let code = `
964
- // Modify item ${itemId}
976
+ // Modify item ${ID}
965
977
  let _modified = true;`;
966
978
  if (Object.keys(restProperties).length > 0) {
967
979
  code += `
968
- _modified = app.modifyItem('${itemId}', ${JSON.stringify(restProperties, null, 2)}) !== false;
969
- if (!_modified) { return { success: false, error: 'no item ${itemId} — nothing was modified' }; }`;
980
+ _modified = app.modifyItem(${ID}, ${jsLiteral(restProperties, 2)}) !== false;
981
+ if (!_modified) { return { success: false, error: 'no item ' + ${ID} + ' — nothing was modified' }; }`;
970
982
  }
971
983
  else {
972
984
  code += `
973
- if (!app.getItemById('${itemId}')) { return { success: false, error: 'no item ${itemId}' }; }`;
985
+ if (!app.getItemById(${ID})) { return { success: false, error: 'no item ' + ${ID} }; }`;
974
986
  }
975
987
  // Handle dynamic content type changes
976
988
  if (contentType !== undefined) {
977
989
  if (contentType === null || contentType === 'none' || contentType === '') {
978
990
  // Remove dynamic content
979
991
  code += `
980
- const entry = app.itemRegistry.get('${itemId}');
992
+ const entry = app.itemRegistry.get(${ID});
981
993
  if (entry && entry.item && app.removeDynamicContent) app.removeDynamicContent(entry.item);`;
982
994
  }
983
995
  else {
@@ -990,8 +1002,8 @@ if (entry && entry.item && app.removeDynamicContent) app.removeDynamicContent(en
990
1002
  if (countdownEndText)
991
1003
  opts.countdownEndText = countdownEndText;
992
1004
  code += `
993
- const entry = app.itemRegistry.get('${itemId}');
994
- if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, '${contentType}', ${JSON.stringify(opts)});`;
1005
+ const entry = app.itemRegistry.get(${ID});
1006
+ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, '${contentType}', ${jsLiteral(opts)});`;
995
1007
  }
996
1008
  }
997
1009
  // AN AUDIO ITEM'S LEVEL IS NOT AN ITEM PROPERTY. modifyItem applies changes
@@ -1003,13 +1015,13 @@ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.it
1003
1015
  if (levelKey) {
1004
1016
  const level = Math.max(0, Math.min(1, properties[levelKey]));
1005
1017
  code += `
1006
- const _ae = app.itemRegistry.get('${itemId}');
1018
+ const _ae = app.itemRegistry.get(${ID});
1007
1019
  if (_ae && _ae.type === 'audio') {
1008
1020
  _ae.properties = _ae.properties || {};
1009
1021
  _ae.properties.audioGain = ${level};
1010
1022
  _ae.properties.gain = ${level};
1011
1023
  const _A = (typeof window !== 'undefined') && window.PinePaperAgent;
1012
- const _m = _A && typeof _A.listMedia === 'function' ? _A.listMedia().find(function(x) { return x.registryId === '${itemId}'; }) : null;
1024
+ const _m = _A && typeof _A.listMedia === 'function' ? _A.listMedia().find(function(x) { return x.registryId === ${ID}; }) : null;
1013
1025
  if (_m && app.audioLayer && typeof app.audioLayer.setVolume === 'function') app.audioLayer.setVolume(${level}, _m.id);
1014
1026
  }`;
1015
1027
  }
@@ -1017,17 +1029,17 @@ if (_ae && _ae.type === 'audio') {
1017
1029
  // does not pass on; applied to a raster, or to the raster an image group holds.
1018
1030
  if (typeof properties.smoothing === 'string') {
1019
1031
  code += `
1020
- const _sm = app.itemRegistry.get('${itemId}');
1032
+ const _sm = app.itemRegistry.get(${ID});
1021
1033
  const _smR = _sm && _sm.item && (_sm.item.className === 'Raster' ? _sm.item
1022
1034
  : (typeof _sm.item.getItem === 'function' ? _sm.item.getItem({ className: 'Raster' }) : null));
1023
- if (_smR) _smR.smoothing = ${JSON.stringify(properties.smoothing)};`;
1035
+ if (_smR) _smR.smoothing = ${jsLiteral(properties.smoothing)};`;
1024
1036
  }
1025
1037
  // No fill on modify too: null / 'transparent' / 'none' clear it outright,
1026
1038
  // after modifyItem, which would otherwise read 'transparent' as black.
1027
1039
  const fillNone = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
1028
1040
  if (fillNone(properties.fillColor) || (properties.fillColor === undefined && properties.color !== undefined && fillNone(properties.color))) {
1029
1041
  code += `
1030
- const _nf = app.itemRegistry.get('${itemId}');
1042
+ const _nf = app.itemRegistry.get(${ID});
1031
1043
  if (_nf && _nf.item) _nf.item.fillColor = null;`;
1032
1044
  }
1033
1045
  // SKEW AND AFFINE, FOR MOCKUPS (round 9 HH). A box face or a phone screen in
@@ -1043,7 +1055,7 @@ if (_nf && _nf.item) _nf.item.fillColor = null;`;
1043
1055
  const affine = skewX !== 0 || skewY !== 0 || matrix !== null;
1044
1056
  if (affine) {
1045
1057
  code += `
1046
- const _af = app.itemRegistry.get('${itemId}');
1058
+ const _af = app.itemRegistry.get(${ID});
1047
1059
  const _afi = _af && _af.item;
1048
1060
  if (_afi) {
1049
1061
  ${skewX !== 0 || skewY !== 0 ? `_afi.shear(Math.tan(${skewX} * Math.PI / 180), Math.tan(${skewY} * Math.PI / 180), _afi.bounds.center);` : ''}
@@ -1054,23 +1066,23 @@ if (_afi) {
1054
1066
  if (modFit) {
1055
1067
  code += `
1056
1068
  let __textFit = null;
1057
- const _tf = app.itemRegistry.get('${itemId}');${emitTextFit('_tf && _tf.item', modFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null) + " || (_tf && _tf.item && _tf.item.data && _tf.item.data.fitBox && _tf.item.data.fitBox.hold)")}`;
1069
+ const _tf = app.itemRegistry.get(${ID});${emitTextFit('_tf && _tf.item', modFit, jsLiteral(anchorHold(properties.anchor ?? properties.origin) ?? null) + " || (_tf && _tf.item && _tf.item.data && _tf.item.data.fitBox && _tf.item.data.fitBox.hold)")}`;
1058
1070
  }
1059
1071
  const textStyle = emitTextStyle('_ts && _ts.item', properties);
1060
1072
  if (textStyle) {
1061
1073
  code += `
1062
- const _ts = app.itemRegistry.get('${itemId}');${textStyle}`;
1074
+ const _ts = app.itemRegistry.get(${ID});${textStyle}`;
1063
1075
  }
1064
1076
  const modLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
1065
1077
  if (modLifetime) {
1066
1078
  code += `
1067
1079
  let __lifetime = null;
1068
- const _lt = app.itemRegistry.get('${itemId}');${emitLifetime('_lt && _lt.item', properties.bornAt, properties.ttl)}`;
1080
+ const _lt = app.itemRegistry.get(${ID});${emitLifetime('_lt && _lt.item', properties.bornAt, properties.ttl)}`;
1069
1081
  }
1070
1082
  if (dataFlags && Object.keys(dataFlags).length > 0) {
1071
1083
  code += `
1072
- const _flagged = app.itemRegistry.get('${itemId}');
1073
- if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item.data, ${JSON.stringify(dataFlags)});`;
1084
+ const _flagged = app.itemRegistry.get(${ID});
1085
+ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item.data, ${jsLiteral(dataFlags)});`;
1074
1086
  }
1075
1087
  const modFont = typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
1076
1088
  // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
@@ -1079,7 +1091,7 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
1079
1091
  if (textStyle && letterSpacingCss(properties.letterSpacing) !== undefined)
1080
1092
  aliased.add('letterSpacing');
1081
1093
  const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
1082
- const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
1094
+ const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${jsLiteral(modIgnored)}, warning: ${jsLiteral(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
1083
1095
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
1084
1096
  code += `
1085
1097
  app.historyManager.saveState();
@@ -1087,11 +1099,11 @@ ${properties.content !== undefined ? `
1087
1099
  // CONTENT UNDER A CONTENT TRACK (round 10, 10.13): the keyframes set the text
1088
1100
  // on every frame, so a direct edit is overwritten at once — it answered
1089
1101
  // success and changed nothing visible.
1090
- const __ctItem = app.getItemById('${itemId}');
1102
+ const __ctItem = app.getItemById(${ID});
1091
1103
  const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
1092
1104
  ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
1093
- ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modFillOpacity ? emitFillOpacityCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
1094
- return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
1105
+ ${modDirection ? emitDirectionCheck(`app.getItemById(${ID})`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById(${ID})`) : ''}${modFillOpacity ? emitFillOpacityCheck(`app.getItemById(${ID})`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById(${ID})`, modStrokePos) : ''}
1106
+ return { success: true, itemId: ${ID}${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${jsLiteral({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
1095
1107
  // Wrapped, because the body now RETURNS early on a miss rather than falling
1096
1108
  // through to an unconditional success. Async only when a font must load
1097
1109
  // before modifyItem re-measures the text.
@@ -1104,8 +1116,9 @@ return { success: true, itemId: '${itemId}'${properties.content !== undefined ?
1104
1116
  * Template for deleting items
1105
1117
  */
1106
1118
  function generateDeleteItemCode(itemId) {
1119
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1107
1120
  return `
1108
- // Delete item ${itemId}
1121
+ // Delete item ${ID}
1109
1122
  //
1110
1123
  // Delegate to app.deleteItem rather than hand-rolling the removal. The old
1111
1124
  // inline version called \`app.itemRegistry.remove(...)\`, which does not exist —
@@ -1122,11 +1135,11 @@ function generateDeleteItemCode(itemId) {
1122
1135
  if (typeof app.deleteItem !== 'function') {
1123
1136
  return { success: false, error: 'app.deleteItem unavailable — update FxTool' };
1124
1137
  }
1125
- const ok = app.deleteItem('${itemId}');
1138
+ const ok = app.deleteItem(${ID});
1126
1139
  if (!ok) {
1127
- return { success: false, error: 'Item not found: ${itemId}' };
1140
+ return { success: false, error: 'Item not found: ' + ${ID} };
1128
1141
  }
1129
- return { success: true, itemId: '${itemId}' };
1142
+ return { success: true, itemId: ${ID} };
1130
1143
  })();
1131
1144
  `.trim();
1132
1145
  }
@@ -1142,6 +1155,92 @@ function generateDeleteItemCode(itemId) {
1142
1155
  export function relationRefusalReasonJs(typeJs) {
1143
1156
  return `(function(){ var ws = (app && app._engineWarnings) || []; for (var i = ws.length - 1; i >= 0; i--) { var w = ws[i]; if (w && w.relation === ${typeJs} && typeof w.message === 'string') return w.message; } return null; })()`;
1144
1157
  }
1158
+ /**
1159
+ * on_event_fire_if FAILS SILENTLY ON A BAD SPEC (1.6.24 probe). The engine
1160
+ * evaluates params.predicate with the ExpressionIR interpreter, which is total:
1161
+ * a string such as "1 > 0" has no `op`, evaluates to 0 and takes the else
1162
+ * branch without a warning. And it pulses params.event, never the target, so a
1163
+ * relation with no event fires nothing at all. Both are refused here with the
1164
+ * shape that works. Only the predicate ops' own fields are checked; any other
1165
+ * op passes, so an op the engine adds later is not refused by a stale list.
1166
+ */
1167
+ export function fireIfProblem(params) {
1168
+ const p = params ?? {};
1169
+ const example = 'e.g. predicate: { op: "cmp", cmp: "eq", a: { op: "graph", read: "data", item: "item_3", property: "score" }, b: { op: "const", value: 3 } }, event: "<event to fire when it holds>"';
1170
+ const walk = (n, path, depth) => {
1171
+ if (depth > 32)
1172
+ return `${path} nests deeper than 32 levels`;
1173
+ if (!n || typeof n !== 'object' || Array.isArray(n) || typeof n.op !== 'string') {
1174
+ return `${path} must be an ExpressionIR node (an object with an "op"), got ${JSON.stringify(n)}`;
1175
+ }
1176
+ const node = n;
1177
+ if (node.op === 'cmp') {
1178
+ if (!['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(node.cmp))
1179
+ return `${path}.cmp must be eq, neq, gt, gte, lt or lte, got ${JSON.stringify(node.cmp)}`;
1180
+ return walk(node.a, `${path}.a`, depth + 1) ?? walk(node.b, `${path}.b`, depth + 1);
1181
+ }
1182
+ if (node.op === 'logic') {
1183
+ if (!['and', 'or', 'not'].includes(node.logic))
1184
+ return `${path}.logic must be and, or or not, got ${JSON.stringify(node.logic)}`;
1185
+ if (!Array.isArray(node.args) || !node.args.length)
1186
+ return `${path}.args must be a non-empty array of nodes`;
1187
+ for (let i = 0; i < node.args.length; i++) {
1188
+ const bad = walk(node.args[i], `${path}.args[${i}]`, depth + 1);
1189
+ if (bad)
1190
+ return bad;
1191
+ }
1192
+ return null;
1193
+ }
1194
+ if (node.op === 'graph') {
1195
+ if (!['data', 'active', 'visible'].includes(node.read))
1196
+ return `${path}.read must be data, active or visible, got ${JSON.stringify(node.read)}`;
1197
+ if (typeof node.item !== 'string' || !node.item)
1198
+ return `${path}.item must name the item to read`;
1199
+ if (node.read === 'data' && (typeof node.property !== 'string' || !node.property))
1200
+ return `${path}.property must name the item.data key to read`;
1201
+ }
1202
+ return null;
1203
+ };
1204
+ const bad = walk(p.predicate, 'params.predicate', 0);
1205
+ if (bad)
1206
+ return `${bad}. ${example}.`;
1207
+ const named = (v) => typeof v === 'string' && v.length > 0;
1208
+ if (!named(p.event) && !named(p.else)) {
1209
+ return 'params.event is missing, so nothing would fire: on_event_fire_if pulses params.event when the predicate holds (and params.else when it does not), never its target. '
1210
+ + 'Make the event to fire with pinepaper_event and pass its id as params.event.';
1211
+ }
1212
+ return null;
1213
+ }
1214
+ /**
1215
+ * fireIfProblem for any relation a tool is about to add: an on_event_fire_if
1216
+ * itself, or one an on_event_add_relation will add when its event fires
1217
+ * (params.type / params.params). Every path that adds relations asks this, so
1218
+ * the check does not depend on which tool built the relation.
1219
+ */
1220
+ export function relationSpecProblem(relationType, params) {
1221
+ const p = (params && typeof params === 'object' ? params : {});
1222
+ if (relationType === 'on_event_fire_if')
1223
+ return fireIfProblem(p);
1224
+ if (typeof relationType === 'string' && VISIBILITY_REACTION.test(relationType))
1225
+ return visibilityProblem(relationType, p);
1226
+ if (relationType === 'on_event_add_relation' && (p.type === 'on_event_fire_if' || (typeof p.type === 'string' && VISIBILITY_REACTION.test(p.type)))) {
1227
+ const innerParams = (p.params && typeof p.params === 'object' ? p.params : {});
1228
+ const inner = p.type === 'on_event_fire_if' ? fireIfProblem(innerParams) : visibilityProblem(p.type, innerParams);
1229
+ return inner ? `the ${String(p.type)} it adds: ${inner.replace(/params\./g, 'params.params.')}` : null;
1230
+ }
1231
+ return null;
1232
+ }
1233
+ const VISIBILITY_REACTION = /^on_(event|enter|exit)_set_visibility$/;
1234
+ /**
1235
+ * A visibility reaction reads params.visible and nothing else: absent means show. `value`, which the other set_*
1236
+ * reactions take, was ignored, so {value: false} SHOWED the item and a hide did nothing (issue #29 O4).
1237
+ */
1238
+ function visibilityProblem(relationType, p) {
1239
+ if (p.visible !== undefined || !('value' in p))
1240
+ return null;
1241
+ return `${relationType} reads params.visible (true shows, false hides; absent shows), not params.value, so this would `
1242
+ + `${p.value === false || p.value === 0 || p.value === 'hidden' ? 'show the item instead of hiding it' : 'do nothing the value says'}. Pass params.visible: ${JSON.stringify(p.value === false || p.value === 0 || p.value === 'hidden' ? false : true)}.`;
1243
+ }
1145
1244
  function generateAddRelationCode(sourceId, targetId, relationType, params) {
1146
1245
  // AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
1147
1246
  // stores a relation to an id it cannot resolve, returns true, and says "it
@@ -1162,7 +1261,7 @@ function generateAddRelationCode(sourceId, targetId, relationType, params) {
1162
1261
  }
1163
1262
  const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
1164
1263
  if (!success) {
1165
- const __why = ${relationRefusalReasonJs(JSON.stringify(relationType))};
1264
+ const __why = ${relationRefusalReasonJs(jsLiteral(relationType))};
1166
1265
  return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}' + (__why ? ': ' + __why : '.') };
1167
1266
  }
1168
1267
  app.historyManager.saveState();
@@ -1210,6 +1309,14 @@ export function emitterCountWarnings(spec) {
1210
1309
  ? `${where} ${asked} is over the ${EMITTER_MAX_PER_BURST}-particle cap per burst of the current studio (${EMITTER_LEGACY_MAX_PER_BURST} on older studios), so it draws at most ${EMITTER_MAX_PER_BURST}. Split it into bursts at the same t for more.`
1211
1310
  : `${where} ${asked} is over the ${EMITTER_LEGACY_MAX_PER_BURST}-particle cap per burst of studios before the ${EMITTER_MAX_PER_BURST} cap, and this studio did not report what it drew: on an older studio it draws ${EMITTER_LEGACY_MAX_PER_BURST}. Split it into bursts at the same t to be sure.`);
1212
1311
  }
1312
+ /**
1313
+ * The scene lint every batch and custom-code call runs asks for warnings only.
1314
+ * Only lintScene().warnings (the text-clipping rule) is used, and a full
1315
+ * lintScene first runs RelationalLinter.analyze, a pairwise pass over every
1316
+ * item, whose result was thrown away. FxTool #220 added { warningsOnly: true },
1317
+ * which skips it and returns the same warnings; a studio before that ignores
1318
+ * the option and runs the full lint, as it always did.
1319
+ */
1213
1320
  /**
1214
1321
  * An emitter spec as the studio reads it, and what the reply says about it.
1215
1322
  *
@@ -1246,6 +1353,9 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1246
1353
  const mismatch = (where, count, velocities) => {
1247
1354
  if (!Array.isArray(velocities) || !velocities.length)
1248
1355
  return;
1356
+ // A count given as a range is not read (said below); comparing it as the default 12 would be a false warning.
1357
+ if (Array.isArray(count))
1358
+ return;
1249
1359
  const n = typeof count === 'number' && Number.isFinite(count) ? Math.round(count) : (assumeDefaultCount ? DEFAULT_COUNT : null);
1250
1360
  if (n === null || n === velocities.length)
1251
1361
  return;
@@ -1263,6 +1373,32 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1263
1373
  if (own.count !== undefined || own.velocities !== undefined)
1264
1374
  mismatch(`bursts[${i}]`, own.count ?? out.count, own.velocities ?? out.velocities);
1265
1375
  });
1376
+ // A RANGE WHERE THE STUDIO READS ONE NUMBER is not drawn as a range (1.6.24
1377
+ // probe: gravity [100, 300] fell to 0, opacity [0.2, 1] to 1, no word). What
1378
+ // it draws instead depends on the studio build: older ones take num()'s
1379
+ // fallback (count [a, b] → 0, so nothing is drawn); FxTool #228 draws the
1380
+ // middle and says so (emitter-range-not-read). Only size, speed, angle,
1381
+ // spawn, lifetime and spin go through range(). The text says what holds on
1382
+ // both, so it is true whichever studio the server is pointed at.
1383
+ const SCALAR_ONLY = ['gravity', 'drag', 'friction', 'opacity', 'fadeIn', 'fadeOut', 'strokeWidth', 'jitter', 'jitterHz', 'aspect', 'sizeEnd', 'count'];
1384
+ const SPEC_ONLY = ['rate', 'start', 'end', 'seed'];
1385
+ const OUTCOME = {
1386
+ count: 'an older studio reads a range here as 0 and draws no particles',
1387
+ };
1388
+ const scalarRanges = (where, o, keys) => {
1389
+ for (const k of keys) {
1390
+ const v = o[k];
1391
+ if (Array.isArray(v) && v.length >= 2) {
1392
+ warnings.push(`${where}.${k} ${JSON.stringify(v)} is not drawn as a range: ${k} takes one number, so the studio draws one value for every particle (its default, or the middle of the range, depending on the build${OUTCOME[k] ? `; ${OUTCOME[k]}` : ''}). Pass the single value you want. Only size, speed, angle, spawn, lifetime and spin take a [min, max] range.`);
1393
+ }
1394
+ }
1395
+ const f = o.fade;
1396
+ if (f && typeof f === 'object' && !Array.isArray(f))
1397
+ scalarRanges(`${where}.fade`, f, ['in', 'out']);
1398
+ };
1399
+ scalarRanges('spec', out, [...SCALAR_ONLY, ...SPEC_ONLY]);
1400
+ bursts.forEach((b, i) => { if (b && typeof b === 'object')
1401
+ scalarRanges(`bursts[${i}]`, b, SCALAR_ONLY); });
1266
1402
  return { spec: out, warnings };
1267
1403
  }
1268
1404
  /**
@@ -1415,15 +1551,16 @@ app.historyManager.saveState();
1415
1551
  * Template for querying relations
1416
1552
  */
1417
1553
  function generateQueryRelationsCode(itemId, relationType, direction = 'outgoing') {
1554
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1418
1555
  if (direction === 'outgoing') {
1419
1556
  const typeArg = relationType ? `'${relationType}'` : 'undefined';
1420
1557
  return `
1421
- // Query outgoing relations for ${itemId}
1422
- const relations = app.getRelations('${itemId}', ${typeArg});
1558
+ // Query outgoing relations for ${ID}
1559
+ const relations = app.getRelations(${ID}, ${typeArg});
1423
1560
 
1424
1561
  // Format results
1425
1562
  const formatted = relations.map(r => ({
1426
- sourceId: r.sourceId || '${itemId}',
1563
+ sourceId: r.sourceId || ${ID},
1427
1564
  // RelationRegistry.getAssociations returns { relation, target, params } —
1428
1565
  // there is no \`targetId\` on it. Reading the wrong field made EVERY edge come
1429
1566
  // back with an undefined destination, so the graph was unreadable through
@@ -1439,13 +1576,13 @@ const formatted = relations.map(r => ({
1439
1576
  else {
1440
1577
  const typeArg = relationType ? `'${relationType}'` : 'undefined';
1441
1578
  return `
1442
- // Query incoming relations for ${itemId}
1443
- const results = app.queryByRelationTarget('${itemId}', ${typeArg});
1579
+ // Query incoming relations for ${ID}
1580
+ const results = app.queryByRelationTarget(${ID}, ${typeArg});
1444
1581
 
1445
1582
  // Format results
1446
1583
  const relations = results.map(r => ({
1447
1584
  sourceId: r.itemId,
1448
- targetId: '${itemId}',
1585
+ targetId: ${ID},
1449
1586
  // queryByTarget returns the real relation name on each result. Hardcoding
1450
1587
  // the filter string labelled every edge 'unknown' on an unfiltered query —
1451
1588
  // which is exactly the query you make when exploring an unfamiliar graph.
@@ -1461,13 +1598,14 @@ const relations = results.map(r => ({
1461
1598
  * Template for simple animation
1462
1599
  */
1463
1600
  function generateAnimateCode(itemId, animationType, speed, intensity, delay) {
1601
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1464
1602
  const extra = (intensity !== undefined ? `,\n animationIntensity: ${intensity}` : '') +
1465
1603
  (delay !== undefined ? `,\n animationDelay: ${delay}` : '');
1466
1604
  return `
1467
- // Apply ${animationType} animation to ${itemId}
1468
- const item = app.getItemById('${itemId}');
1605
+ // Apply ${animationType} animation to ${ID}
1606
+ const item = app.getItemById(${ID});
1469
1607
  if (!item) {
1470
- throw new Error('Item not found: ${itemId}');
1608
+ throw new Error('Item not found: ' + ${ID});
1471
1609
  }
1472
1610
  app.animate(item, {
1473
1611
  animationType: '${animationType}',
@@ -1483,13 +1621,13 @@ app.animate(item, {
1483
1621
  if (r) {
1484
1622
  return {
1485
1623
  success: false,
1486
- itemId: '${itemId}',
1624
+ itemId: ${ID},
1487
1625
  error: 'Unknown animationType "' + r.requested + '" — nothing will move.',
1488
1626
  requested: r.requested,
1489
1627
  known: r.known,
1490
1628
  };
1491
1629
  }
1492
- return { success: true, itemId: '${itemId}', animationType: '${animationType}' };
1630
+ return { success: true, itemId: ${ID}, animationType: '${animationType}' };
1493
1631
  })();
1494
1632
  `.trim();
1495
1633
  }
@@ -1539,10 +1677,11 @@ function normalizeKeyframePositions(keyframes) {
1539
1677
  * "item_7 is not on the canvas" is actionable, silence is not.
1540
1678
  */
1541
1679
  function requireItem(itemId, what) {
1680
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1542
1681
  const isRef = typeof itemId === 'string' && itemId.startsWith('$');
1543
1682
  const targetExpr = isRef
1544
- ? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || '${itemId}')`
1545
- : `'${itemId}'`;
1683
+ ? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || ${ID})`
1684
+ : `${ID}`;
1546
1685
  return `
1547
1686
  const _targetId = ${targetExpr};
1548
1687
  const _target = (app.getItemById && app.getItemById(_targetId))
@@ -1580,6 +1719,7 @@ function unanimatedTextKeysJs(keys) {
1580
1719
  /** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
1581
1720
  const UNANIMATED_TEXT_KEYS_SENTENCE = `' is not an animatable property in this studio (app.listAnimatableProperties does not list it), so it had no effect. The current studio animates fontStyle, textAlign, lineHeight and letterSpacing; here, set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.'`;
1582
1721
  function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false, easingAt) {
1722
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1583
1723
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1584
1724
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1585
1725
  const opts = { duration: calculatedDuration, loop };
@@ -1661,9 +1801,9 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1661
1801
  const easingAtCheck = easingAt !== undefined ? `
1662
1802
  if (__opts && Object.prototype.hasOwnProperty.call(__opts, 'easingAt')) __warn.push('easingAt ' + ${JSON.stringify(JSON.stringify(easingAt))} + ' was not applied: this studio predates it, so every easing was read as the move ARRIVING at its key. Put each easing on the key the move goes to instead.');` : '';
1663
1803
  const runtimeWarning = `${textKeysJs ? ', ...(__ignored.length ? { ignoredProperties: __ignored } : {})' : ''}, ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
1664
- const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
1804
+ const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `${ID}`;
1665
1805
  return `
1666
- // Apply keyframe animation to ${itemId}
1806
+ // Apply keyframe animation to ${ID}
1667
1807
  (function() {
1668
1808
  ${requireItem(itemId, 'the animation')}${audioLevelGuard}
1669
1809
  // The track as it was, so the result can say what happened to it: merged
@@ -1763,15 +1903,16 @@ function emitGeneratorRun(generatorName, paramsJson, region) {
1763
1903
  * Template for applying effects
1764
1904
  */
1765
1905
  function generateApplyEffectCode(itemId, effectType, params) {
1906
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1766
1907
  return `
1767
- // Apply ${effectType} effect to ${itemId}
1768
- const item = app.getItemById('${itemId}');
1908
+ // Apply ${effectType} effect to ${ID}
1909
+ const item = app.getItemById(${ID});
1769
1910
  if (!item) {
1770
- throw new Error('Item not found: ${itemId}');
1911
+ throw new Error('Item not found: ' + ${ID});
1771
1912
  }
1772
1913
  app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
1773
1914
 
1774
- ({ success: true, itemId: '${itemId}', effectType: '${effectType}' });
1915
+ ({ success: true, itemId: ${ID}, effectType: '${effectType}' });
1775
1916
  `.trim();
1776
1917
  }
1777
1918
  /**
@@ -2082,6 +2223,25 @@ const MEDIA_PURGE = `
2082
2223
  if (__PA && typeof __PA.listMedia === 'function' && typeof __PA.removeMedia === 'function') {
2083
2224
  __PA.listMedia().forEach(function(m) { try { if (__PA.removeMedia(m.id)) __mediaRemoved++; } catch (_) { /* keep clearing */ } });
2084
2225
  }`;
2226
+ /**
2227
+ * THE ITEM-ID COUNTER STARTS AGAIN AFTER A CLEAR (O1, mcp-server#29). Run after clearCanvas.
2228
+ *
2229
+ * The studio's clearCanvas keeps uploaded clips, so it calls itemRegistry.clear with preserveTypes, and that branch
2230
+ * never resets nextId: a batch that made item_1 made item_2 when replayed after a clear. The rule: the next id is one
2231
+ * past the highest item_N still registered, so 1 on an empty canvas and never an id a kept clip holds. (An undo of
2232
+ * the clear restores the old items through rebuildFromCanvas, which moves nextId past them, as the studio's own
2233
+ * full clear already relies on.) The previous batch's $N ids named items that are gone, so they are forgotten too.
2234
+ */
2235
+ const ID_COUNTER_RESET = `
2236
+ if (app.itemRegistry && typeof app.itemRegistry.nextId === 'number' && typeof app.itemRegistry.getAll === 'function') {
2237
+ let __maxLive = 0;
2238
+ app.itemRegistry.getAll().forEach(function (e) {
2239
+ const m = /^item_(\\d+)$/.exec((e && (e.id || e.itemId || (e.item && e.item.data && (e.item.data.registryId || e.item.data.id)))) || '');
2240
+ if (m) __maxLive = Math.max(__maxLive, Number(m[1]));
2241
+ });
2242
+ app.itemRegistry.nextId = __maxLive + 1;
2243
+ }
2244
+ if (typeof window !== 'undefined') window.__pinepaper_batch_item_ids = [];`;
2085
2245
  /**
2086
2246
  * Template for clearing all items from canvas
2087
2247
  */
@@ -2105,6 +2265,7 @@ if (typeof app.clearCanvas === 'function') {
2105
2265
  if (app.itemRegistry) app.itemRegistry.clear();
2106
2266
  if (app.relationRegistry) app.relationRegistry.clear();
2107
2267
  }
2268
+ ${ID_COUNTER_RESET}
2108
2269
 
2109
2270
  if (app.historyManager) app.historyManager.saveState();
2110
2271
 
@@ -2355,13 +2516,23 @@ export function planKeyframeMerges(operations) {
2355
2516
  * through modifyItem after create, so the engine's own bookkeeping runs.
2356
2517
  */
2357
2518
  const BRAND_FILL_TYPES = new Set(['circle', 'star', 'rectangle', 'triangle', 'polygon', 'ellipse', 'pentagon', 'hexagon', 'diamond', 'arrow', 'heart']);
2519
+ /** A closed shape given a stroke colour and no fill of any spelling: drawn as an outline (issue #29 O7). */
2520
+ function isStrokeOnly(itemType, props) {
2521
+ if (!BRAND_FILL_TYPES.has(itemType))
2522
+ return false;
2523
+ if (props.strokeColor === undefined || props.strokeColor === null)
2524
+ return false;
2525
+ return ['color', 'fillColor', 'fill', 'gradient'].every((k) => props[k] === undefined);
2526
+ }
2527
+ const OUTLINE_NOTE = 'none: a strokeColor with no fill draws an outline. Pass fillColor to fill it.';
2358
2528
  function withBrandDefaults(code, itemType, props) {
2359
2529
  if (props.noBrand === true)
2360
2530
  return code;
2361
2531
  const isText = itemType === 'text';
2362
2532
  if (!isText && !BRAND_FILL_TYPES.has(itemType))
2363
2533
  return code;
2364
- const hasColor = ['color', 'fillColor', 'fill'].some((k) => props[k] !== undefined);
2534
+ // An outline is a colour choice: the brand does not fill it in.
2535
+ const hasColor = ['color', 'fillColor', 'fill'].some((k) => props[k] !== undefined) || isStrokeOnly(itemType, props);
2365
2536
  const hasFont = props.fontFamily !== undefined;
2366
2537
  if (hasColor && (!isText || hasFont))
2367
2538
  return code;
@@ -2963,6 +3134,11 @@ export class PinePaperCodeGenerator {
2963
3134
  return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
2964
3135
  })();`.trim();
2965
3136
  }
3137
+ {
3138
+ const problem = relationSpecProblem(validated.relationType, validated.params);
3139
+ if (problem)
3140
+ return `(function() { return { success: false, relationType: ${jsLiteral(validated.relationType)}, error: ${jsLiteral(problem)} }; })();`;
3141
+ }
2966
3142
  if (validated.relationType === 'moves_along_path') {
2967
3143
  return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
2968
3144
  }
@@ -3639,6 +3815,15 @@ if (typeof _gen === 'function') {
3639
3815
  for (const relation of relations) {
3640
3816
  const { source, target, type, params = {} } = relation;
3641
3817
  const paramsStr = JSON.stringify(params);
3818
+ const specProblem = relationSpecProblem(type, params);
3819
+ if (specProblem) {
3820
+ codeParts.push(`
3821
+ // Refused relation: ${source} ${type} ${target}
3822
+ (results.relationsRefused = results.relationsRefused || []).push(${JSON.stringify({ source, target, type, reason: specProblem })});
3823
+ results.success = false;
3824
+ results.error = 'refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';`);
3825
+ continue;
3826
+ }
3642
3827
  codeParts.push(`
3643
3828
  // Add relation: ${source} ${type} ${target}
3644
3829
  (function() {
@@ -3650,7 +3835,7 @@ if (typeof _gen === 'function') {
3650
3835
  if (app.addRelation(sourceId, targetId, '${type}', params)) {
3651
3836
  results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3652
3837
  } else {
3653
- const __why = ${relationRefusalReasonJs(JSON.stringify(type))};
3838
+ const __why = ${relationRefusalReasonJs(jsLiteral(type))};
3654
3839
  (results.relationsRefused = results.relationsRefused || []).push({ source: '${source}', target: '${target}', type: '${type}', reason: __why || 'the studio refused it' });
3655
3840
  results.success = false;
3656
3841
  results.error = 'the studio refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';
@@ -4068,7 +4253,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4068
4253
  if (app.patternGroup) app.patternGroup.removeChildren();
4069
4254
  if (app.itemRegistry) app.itemRegistry.clear();
4070
4255
  if (app.relationRegistry) app.relationRegistry.clear();
4071
- }
4256
+ }${ID_COUNTER_RESET}
4072
4257
  `;
4073
4258
  }
4074
4259
  if (canvasPreset) {
@@ -4311,7 +4496,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4311
4496
  if (app.patternGroup) app.patternGroup.removeChildren();
4312
4497
  if (app.itemRegistry) app.itemRegistry.clear();
4313
4498
  if (app.relationRegistry) app.relationRegistry.clear();
4314
- }
4499
+ }${ID_COUNTER_RESET}
4315
4500
  `;
4316
4501
  }
4317
4502
  else {
@@ -4319,7 +4504,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4319
4504
  // Clear items but preserve background
4320
4505
  if (app.textItemGroup) app.textItemGroup.removeChildren();
4321
4506
  if (app.itemRegistry) app.itemRegistry.clear();
4322
- if (app.relationRegistry) app.relationRegistry.clear();
4507
+ if (app.relationRegistry) app.relationRegistry.clear();${ID_COUNTER_RESET}
4323
4508
  `;
4324
4509
  }
4325
4510
  if (canvasPreset) {
@@ -4374,7 +4559,8 @@ throw new Error('Unknown diagram mode action: ${action}');
4374
4559
  || (typeof v.reason === 'string' && v.reason)
4375
4560
  || 'the operation reported failure without naming a reason';
4376
4561
  };
4377
- // $N is the Nth item THIS batch created (the documented contract). The
4562
+ // $N is the Nth item THIS batch made (the documented contract): create and group ops take a slot each, in op
4563
+ // order (O2, mcp-server#29: a group is an item every later op addresses, and it had no slot). The
4378
4564
  // previous batch's ids are kept only to name the right id when a $N reaches
4379
4565
  // past what this batch has made: picking it silently shifted every $N in a
4380
4566
  // batch that also creates (B115 follow-up).
@@ -4383,9 +4569,11 @@ throw new Error('Unknown diagram mode action: ${action}');
4383
4569
  const __ref = function (n) {
4384
4570
  if (itemIds[n] !== undefined) return itemIds[n];
4385
4571
  const prev = __prevBatchIds[n];
4386
- throw new Error('$' + n + ' is the item number ' + n + ' THIS batch created, and it has created ' + itemIds.length + ' so far.'
4387
- + (prev ? ' In the previous batch $' + n + ' was ' + prev + ' — pass that id instead.' : ' Put the create ops first, or pass the item id.'));
4572
+ throw new Error('$' + n + ' is item number ' + n + ' THIS batch made (create and group ops are numbered, in op order, from $0), and it has made ' + itemIds.length + ' so far.'
4573
+ + (prev ? ' In the previous batch $' + n + ' was ' + prev + ' — pass that id instead.' : ' Put the ops that make it first, or pass the item id.'));
4388
4574
  };
4575
+ // A failure names the item, not the "$1" that addressed it; the ref as written when the batch never made it.
4576
+ const __refName = function (n) { return itemIds[n] !== undefined ? itemIds[n] : '$' + n; };
4389
4577
  const results = [];
4390
4578
  let success = true;
4391
4579
  // A failure's words, never "undefined" for a thrown non-Error.
@@ -4397,7 +4585,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4397
4585
  const __ppLintNow = function () {
4398
4586
  try {
4399
4587
  if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
4400
- const r = app.lintScene();
4588
+ const r = app.lintScene({ warningsOnly: true });
4401
4589
  return r && Array.isArray(r.warnings) ? r.warnings : [];
4402
4590
  } catch (e) {
4403
4591
  return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
@@ -4421,12 +4609,14 @@ throw new Error('Unknown diagram mode action: ${action}');
4421
4609
  // $N reference: a batch of [create ×5, keyframe_animate ×5, create ×5,
4422
4610
  // keyframe_animate ×5] silently re-animated the FIRST five items and
4423
4611
  // left the second five without keyframes (mis-oriented/mis-animated
4424
- // vehicles, 2026-07-30). `group` already follows this rule by
4425
- // returning groupId instead of itemId.
4426
- const claimsItemSlot = op.type === 'create';
4612
+ // vehicles, 2026-07-30). A `group` MAKES an item (the group), so it
4613
+ // claims a slot too (O2, mcp-server#29): [create, create, group,
4614
+ // keyframe_animate "$2"] keys the group.
4615
+ const claimsItemSlot = op.type === 'create' || op.type === 'group';
4427
4616
  // The item this op acts on, for a failure's itemId: its own id field, else what its result names.
4428
4617
  const o = op;
4429
4618
  const opItem = [o.itemId, o.targetId, o.sourceId, o.id].find((v) => typeof v === 'string') ?? null;
4619
+ const opItemJs = typeof opItem === 'string' && /^\$\d+$/.test(opItem) ? `__refName(${opItem.slice(1)})` : JSON.stringify(opItem);
4430
4620
  const opStep = typeof o._step === 'string' ? `op ${index}: ${o._step}` : `op ${index} (${op.type})`;
4431
4621
  code += `
4432
4622
  // Operation ${index}: ${op.type}
@@ -4444,7 +4634,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4444
4634
  if (failed${index}) {
4445
4635
  // ATOMIC: throw WITHOUT recording, and let the catch below record it
4446
4636
  // once. Doing both put two entries in for one failed op.
4447
- ${isAtomic ? `const __e${index} = new Error(failed${index}); __e${index}.itemId = (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}; throw __e${index};` : `results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}, reason: failed${index}, error: failed${index}, result: result${index} });
4637
+ ${isAtomic ? `const __e${index} = new Error(failed${index}); __e${index}.itemId = (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${opItemJs}; throw __e${index};` : `results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${opItemJs}, reason: failed${index}, error: failed${index}, result: result${index} });
4448
4638
  success = false;`}
4449
4639
  } else {
4450
4640
  results.push({ index: ${index}, success: true, result: result${index} });
@@ -4454,7 +4644,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4454
4644
  }
4455
4645
  } catch (opError) {
4456
4646
  const __r${index} = _why(opError, 'operation ${index} (${op.type}) threw with no message');
4457
- results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (opError && typeof opError.itemId === 'string') ? opError.itemId : ${JSON.stringify(opItem)}, reason: __r${index}, error: __r${index} });
4647
+ results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (opError && typeof opError.itemId === 'string') ? opError.itemId : ${opItemJs}, reason: __r${index}, error: __r${index} });
4458
4648
  ${isAtomic ? 'throw opError;' : 'success = false;'}
4459
4649
  }
4460
4650
  `;
@@ -4576,7 +4766,7 @@ return __created;
4576
4766
  // at run time, so it is generated with a placeholder and the runtime
4577
4767
  // variable put in its place.
4578
4768
  const single = generateModifyItemCode('__MID__', (op.properties || {})).trim()
4579
- .replace(/'__MID__'/g, '__mid')
4769
+ .replace(/"__MID__"/g, '__mid')
4580
4770
  .replace(/__MID__/g, "' + __mid + '")
4581
4771
  .replace(/;\s*$/, '');
4582
4772
  return `
@@ -4601,13 +4791,18 @@ return __r;
4601
4791
  const targetId = ${animItemRef};
4602
4792
  const item = app.getItemById(targetId);
4603
4793
  if (!item) throw new Error('Item not found: ' + targetId);
4604
- app.animate(item, { animationType: '${op.animationType}', ...${animOpts} });
4605
- return { itemId: targetId, animationType: '${op.animationType}' };
4794
+ app.animate(item, { animationType: ${JSON.stringify(String(op.animationType))}, ...${animOpts} });
4795
+ return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationType))} };
4606
4796
  `;
4607
4797
  case 'relation':
4608
4798
  const srcRef = batchRef(op.sourceId);
4609
4799
  const tgtRef = batchRef(op.targetId);
4610
4800
  const relOpts = JSON.stringify(op.relationOptions || {});
4801
+ const fireIf = relationSpecProblem(op.relationType, op.relationOptions);
4802
+ if (fireIf)
4803
+ return `
4804
+ throw new Error(${JSON.stringify(String(op.relationType) + ': ' + fireIf)});
4805
+ `;
4611
4806
  return `
4612
4807
  const sourceId = ${srcRef};
4613
4808
  const targetId = ${tgtRef};
@@ -4615,17 +4810,17 @@ const source = app.getItemById(sourceId);
4615
4810
  const target = app.getItemById(targetId);
4616
4811
  if (!source) throw new Error('Source not found: ' + sourceId);
4617
4812
  if (!target) throw new Error('Target not found: ' + targetId);
4618
- const relationId = app.addRelation(source, target, '${op.relationType}', ${relOpts});
4813
+ const relationId = app.addRelation(source, target, ${JSON.stringify(String(op.relationType))}, ${relOpts});
4619
4814
  // NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false
4620
4815
  // when it refuses — spring_follow "not loaded yet" — and this op returned
4621
4816
  // { relationId: false } with nothing marking it failed, so the batch said
4622
4817
  // success over a relation that does not exist.
4623
4818
  if (!relationId) {
4624
- const __why = ${relationRefusalReasonJs(JSON.stringify(op.relationType))};
4819
+ const __why = ${relationRefusalReasonJs(jsLiteral(op.relationType))};
4625
4820
  return { success: false, relationId: false, sourceId, targetId, relationType: ${JSON.stringify(op.relationType)},
4626
4821
  error: 'the studio refused the ' + ${JSON.stringify(op.relationType)} + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };
4627
4822
  }
4628
- return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4823
+ return { relationId, sourceId, targetId, relationType: ${JSON.stringify(String(op.relationType))} };
4629
4824
  `;
4630
4825
  case 'delete':
4631
4826
  const delRef = batchRef(op.itemId);
@@ -4642,7 +4837,7 @@ return { itemId: targetId, deleted: true };
4642
4837
  const refs = (op.itemIds || []).map((ref) => batchRef(ref));
4643
4838
  const idsArrayExpr = `[${refs.join(', ')}]`;
4644
4839
  const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
4645
- // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
4840
+ // Returns { groupId, itemId }: the group takes the next $N slot (O2), as a create does.
4646
4841
  return `
4647
4842
  return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4648
4843
  })();
@@ -5238,7 +5433,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5238
5433
  const __lint = (function () {
5239
5434
  if (typeof app.lintScene !== 'function') return null;
5240
5435
  try {
5241
- const r = app.lintScene();
5436
+ const r = app.lintScene({ warningsOnly: true });
5242
5437
  return r && Array.isArray(r.warnings) ? r.warnings : null;
5243
5438
  } catch (e) {
5244
5439
  return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run before the export: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
@@ -5325,14 +5520,36 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5325
5520
  });
5326
5521
 
5327
5522
  let result = { success: false, platform, format, quality, framing };
5328
- // A STILL SHOWS WHAT IS ALIVE AT ITS MOMENT. Lifetimes that fall back to the
5523
+ // THE MOMENT A STILL SHOWS (issue #29 O6). With no time, a still of a scene
5524
+ // that is playing was whatever frame the live playhead had reached when the
5525
+ // call landed: an orbiting planet sat at a different angle on every run, and
5526
+ // nowhere near its place in the video's first frame. A playing scene's still
5527
+ // is now its first frame (time 0), and the result says so; a stopped
5528
+ // playhead is a moment someone chose, and is kept.
5529
+ let __stillT = ${stillTime !== undefined ? stillTime : 'null'};
5530
+ let __stillTimeNote = null;
5531
+ ${stillTime === undefined ? ` if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1 && typeof app.setPlaybackTime === 'function') {
5532
+ const __tr = app.transport;
5533
+ let __playing = !!(__tr && __tr.isPlaying) || !!(app.isPlayingKeyframes && !app.isAnimationPaused);
5534
+ if (!__playing && typeof app.playbackTime === 'number') {
5535
+ // The flags are not the only clock: watch the playhead for two frames.
5536
+ const __t0 = app.playbackTime;
5537
+ await new Promise((r) => { if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(r)); else setTimeout(r, 40); });
5538
+ __playing = app.playbackTime !== __t0;
5539
+ }
5540
+ if (__playing) {
5541
+ __stillT = 0;
5542
+ __stillTimeNote = 'the scene was playing, so this still is its first frame (time 0), the frame a video starts on. Pass time for another moment.';
5543
+ }
5544
+ }
5545
+ ` : ''} // A STILL SHOWS WHAT IS ALIVE AT ITS MOMENT. Lifetimes that fall back to the
5329
5546
  // MCP's frame callback are applied by the frame loop, and a seek does not run
5330
5547
  // it — so a png at time 0.8 s drew nothing staged at 0.8 s, and an item past
5331
5548
  // its ttl still showed (retest). The visibility pass runs here for stills.
5332
5549
  if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1) {
5333
5550
  const __L = globalThis.__ppMcp;
5334
5551
  if (__L && __L.lifetimeApp === app && typeof __L.applyLifetimes === 'function') {
5335
- try { __L.applyLifetimes(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"}); } catch (_) { /* the still renders either way */ }
5552
+ try { __L.applyLifetimes(__stillT !== null ? __stillT : (typeof app.playbackTime === 'number' ? app.playbackTime : 0)); } catch (_) { /* the still renders either way */ }
5336
5553
  }
5337
5554
  }
5338
5555
  // The engine's report on the audio track of THIS export (FxTool b41860aa:
@@ -5341,18 +5558,19 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5341
5558
  const __vx = app.exportEngine && app.exportEngine.videoExporter;
5342
5559
  if (__vx && 'lastAudioReport' in __vx) __vx.lastAudioReport = null;
5343
5560
  if (__vx && 'lastVideoReport' in __vx) __vx.lastVideoReport = null;
5344
- ${stillTime !== undefined ? `
5345
5561
  // A STILL AT A CHOSEN MOMENT (round 7 X, 1.57). Without this a png is
5346
5562
  // whatever frame the playhead is on — two identical builds gave PNGs that
5347
5563
  // differed in the animated streak while their MP4s were byte-identical.
5348
5564
  // Seek, render, and put the playhead back afterwards.
5349
5565
  const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
5350
- if (typeof app.setPlaybackTime !== 'function') {
5351
- return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen time.' };
5566
+ if (__stillT !== null) {
5567
+ if (typeof app.setPlaybackTime !== 'function') {
5568
+ return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen time.' };
5569
+ }
5570
+ app.setPlaybackTime(__stillT);
5571
+ if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
5352
5572
  }
5353
- app.setPlaybackTime(${stillTime});
5354
- if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
5355
- ` : ''}
5573
+
5356
5574
  try {
5357
5575
  // jpg / webp render as png, then re-encode below.
5358
5576
  switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {
@@ -5387,7 +5605,7 @@ ${stillTime !== undefined ? `
5387
5605
  if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
5388
5606
  // A video in the region is waited for at this time (round 12, 12.5):
5389
5607
  // the render drew before the clip's seek landed.
5390
- if (typeof app.awaitMediaAt === 'function') await app.awaitMediaAt(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"});
5608
+ if (typeof app.awaitMediaAt === 'function') await app.awaitMediaAt(__stillT !== null ? __stillT : (typeof app.playbackTime === 'number' ? app.playbackTime : 0));
5391
5609
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
5392
5610
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
5393
5611
  // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
@@ -5444,7 +5662,9 @@ ${stillTime !== undefined ? `
5444
5662
  || app.canvasSize
5445
5663
  || { width: dimensions.width, height: dimensions.height };
5446
5664
  const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
5447
- const png = await app.exportEngine.exportPNG({ scale: pngScale });
5665
+ // The engine evaluates the scene at time itself (keyframes, relations, loops); without it, a playing
5666
+ // scene's still is left as the live frame.
5667
+ const png = await app.exportEngine.exportPNG({ scale: pngScale, ...(__stillT !== null ? { time: __stillT } : {}) });
5448
5668
  const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
5449
5669
  result = {
5450
5670
  success: true,
@@ -6168,8 +6388,10 @@ ${(() => {
6168
6388
  warning: `${frames} frames at ${Math.round(fps * 1000) / 1000} fps is ${actual.toFixed(3)} s — ${((actual - videoDuration) * 1000).toFixed(1)} ms over the ${videoDuration} s asked for. For a hard cap (e.g. a 6 s bumper) use duration ${fit.toFixed(4)} (${Math.floor(videoDuration * fps)} frames).`,
6169
6389
  })};\n`;
6170
6390
  })()}
6171
- ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
6172
- if (result && result.success) result.time = ${stillTime};` : ''}
6391
+ if (__stillT !== null) {
6392
+ try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
6393
+ if (result && result.success) { result.time = __stillT; if (__stillTimeNote) result.stillTimeNote = __stillTimeNote; }
6394
+ }
6173
6395
  // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the
6174
6396
  // camera's frame map, so a HUD item is placed as if the camera had not moved.
6175
6397
  if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(${JSON.stringify(format)}) !== -1) {
@@ -6897,7 +7119,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6897
7119
  // unchanged. The map itself cannot be moved without breaking geoToCanvas
6898
7120
  // (it projects against the untransformed map, so markers would land in the
6899
7121
  // wrong place). Until the engine has a map view transform, say so.
6900
- return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('pan'))} }; })();`;
7122
+ return `(function() { return { success: false, error: ${jsLiteral(MAP_VIEW_REFUSAL('pan'))} }; })();`;
6901
7123
  }
6902
7124
  /**
6903
7125
  * Generate code to zoom the map
@@ -6910,7 +7132,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6910
7132
  if (validated.duration !== undefined)
6911
7133
  options.duration = validated.duration;
6912
7134
  // Refused for the same reason as pan (see generatePanMap).
6913
- return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
7135
+ return `(function() { return { success: false, error: ${jsLiteral(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
6914
7136
  }
6915
7137
  /**
6916
7138
  * Generate code to export map configuration
@@ -7859,7 +8081,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7859
8081
  const __ppLintNow = function () {
7860
8082
  try {
7861
8083
  if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
7862
- const r = app.lintScene();
8084
+ const r = app.lintScene({ warningsOnly: true });
7863
8085
  return r && Array.isArray(r.warnings) ? r.warnings : [];
7864
8086
  } catch (e) {
7865
8087
  return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
@@ -7877,8 +8099,10 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7877
8099
  reason: __ppReason, error: __ppReason, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
7878
8100
  }
7879
8101
  // Only wrapped when the lint has something to say: otherwise the value is returned exactly as before.
8102
+ // __ppWrapped marks the wrapper itself: a code that returned undefined loses the __ppValue key on its way out
8103
+ // of the page (JSON drops undefined), and without the mark the server read the bare wrapper as the value, {}.
7880
8104
  const __ppL = __ppLintNow();
7881
- return __ppL.length ? { __ppValue: __ppValue, __ppLint: __ppL } : __ppValue;
8105
+ return __ppL.length ? { __ppWrapped: true, __ppValue: __ppValue, __ppLint: __ppL } : __ppValue;
7882
8106
  })();
7883
8107
  `.trim();
7884
8108
  }
@@ -8209,7 +8433,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
8209
8433
  `.trim();
8210
8434
  }
8211
8435
  generateImportImage(input) {
8212
- const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset } = input;
8436
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset, name } = input;
8213
8437
  // IMPORTSHADOW: the shadow keys, applied through the engine's own modify
8214
8438
  // door once the image is placed (and masked) — the route modify_item takes.
8215
8439
  const shadow = {};
@@ -8327,6 +8551,11 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8327
8551
  ? { applied: true, shadowColor: placed.shadowColor ? String(placed.shadowColor.toCSS ? placed.shadowColor.toCSS(true) : placed.shadowColor) : null, shadowBlur: placed.shadowBlur }
8328
8552
  : { applied: false, warning: 'the image was imported but its shadow was not applied — set it with pinepaper_modify_item on ' + itemId + '.' };
8329
8553
  }
8554
+ ` : ''}${name ? ` // PATCHIMG24: an image had no name, so a batch patch (which addresses by
8555
+ // name) could not reach it. Set where the registry's name lookup reads it,
8556
+ // on what is drawn (the mask group when masked).
8557
+ placed.name = ${JSON.stringify(name)};
8558
+ if (placed.data) placed.data.name = ${JSON.stringify(name)};
8330
8559
  ` : ''} const b = placed.bounds;
8331
8560
  if (!b || !(b.width > 0) || !(b.height > 0)) {
8332
8561
  return { success: false, itemId: itemId, error: 'the image was placed with no size (' + (b ? b.width + 'x' + b.height : 'no bounds') + ') — it may not have loaded. Nothing visible was drawn.' };
@@ -8334,7 +8563,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8334
8563
  return {
8335
8564
  success: true,
8336
8565
  itemId: itemId,
8337
- message: 'Image imported and placed on canvas.',
8566
+ message: 'Image imported and placed on canvas.',${name ? `\n name: ${JSON.stringify(name)},` : ''}
8338
8567
  bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
8339
8568
  // Stored smaller than its source (the studio's cap): said, with the
8340
8569
  // way to keep every pixel. The studio reports native size where it can.
@@ -8701,7 +8930,7 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
8701
8930
  case 'create': {
8702
8931
  const spec = { id: v.id, kind: v.kind, members: v.members, params: v.params || {}, ...(v.window ? { window: v.window } : {}) };
8703
8932
  body = `${need('addPattern')}
8704
- if (!${S(v.kind ?? null)}) return { success: false, error: 'create needs kind: phase_wave or stagger' };
8933
+ if (!${S(v.kind ?? null)}) return { success: false, error: ${S('create needs kind: ' + PATTERN_KINDS.join(', '))} };
8705
8934
  if (!${S(v.members ?? null)}) return { success: false, error: 'create needs members: { names: [...] }, { ids: [...] }, { tag } or an array' };
8706
8935
  const r = app.addPattern(${S(spec)});
8707
8936
  return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
@@ -9367,6 +9596,8 @@ ${resolveSource}
9367
9596
  })();`.trim();
9368
9597
  }
9369
9598
  generateTransform(input) {
9599
+ // Every id is a quoted literal: an id with a quote in it was a syntax error, and an id is caller text.
9600
+ const ID = JSON.stringify(input.itemId || '');
9370
9601
  switch (input.action) {
9371
9602
  case 'fit': {
9372
9603
  const mode = input.mode || 'contain';
@@ -9378,31 +9609,37 @@ ${resolveSource}
9378
9609
  // id space from canvas item ids. PinePaperAgent.fitToFrame resolves either.
9379
9610
  const A = (typeof window !== 'undefined') && window.PinePaperAgent;
9380
9611
  if (A && typeof A.fitToFrame === 'function') {
9381
- const ok = A.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
9612
+ const ok = A.fitToFrame(${ID}, ${JSON.stringify(mode)});
9382
9613
  return ok
9383
- ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
9384
- : { success: false, error: 'Item not found or not fittable: ${input.itemId}' };
9614
+ ? { success: true, action: 'fit', itemId: ${ID}, mode: ${JSON.stringify(mode)} }
9615
+ : { success: false, error: 'Item not found or not fittable: ' + ${ID} };
9385
9616
  }
9386
9617
  if (typeof app.fitToFrame !== 'function') {
9387
9618
  return { success: false, error: 'fitToFrame unavailable — update FxTool' };
9388
9619
  }
9389
- const ok = app.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
9620
+ const ok = app.fitToFrame(${ID}, ${JSON.stringify(mode)});
9390
9621
  return ok
9391
- ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
9392
- : { success: false, error: 'Item not found: ${input.itemId}' };
9622
+ ? { success: true, action: 'fit', itemId: ${ID}, mode: ${JSON.stringify(mode)} }
9623
+ : { success: false, error: 'Item not found: ' + ${ID} };
9393
9624
  })();`.trim();
9394
9625
  }
9395
9626
  case 'nudge': {
9396
- const dx = input.dx ?? 0;
9397
- const dy = input.dy ?? 0;
9627
+ const dx = Number(input.dx ?? 0);
9628
+ const dy = Number(input.dy ?? 0);
9629
+ // THROUGH modifyItem (issue #29 O8). Writing item.position directly
9630
+ // skipped the engine's bookkeeping: an item with a loop animation kept
9631
+ // its old home (data.originalPosition), and the change check, which
9632
+ // reads authored state, reported the move as no change.
9398
9633
  return `
9399
9634
  // Nudge item position
9400
9635
  (function() {
9401
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9402
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
9403
- entry.item.position.x += ${dx};
9404
- entry.item.position.y += ${dy};
9405
- return { success: true, action: 'nudge', itemId: '${input.itemId}', dx: ${dx}, dy: ${dy}, position: { x: entry.item.position.x, y: entry.item.position.y } };
9636
+ const entry = app.itemRegistry.get(${ID});
9637
+ if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
9638
+ const x = entry.item.position.x + ${dx};
9639
+ const y = entry.item.position.y + ${dy};
9640
+ if (typeof app.modifyItem === 'function') app.modifyItem(${ID}, { x: x, y: y });
9641
+ else { entry.item.position.x = x; entry.item.position.y = y; }
9642
+ return { success: true, action: 'nudge', itemId: ${ID}, dx: ${dx}, dy: ${dy}, position: { x: entry.item.position.x, y: entry.item.position.y } };
9406
9643
  })();`.trim();
9407
9644
  }
9408
9645
  case 'flip': {
@@ -9410,10 +9647,10 @@ ${resolveSource}
9410
9647
  return `
9411
9648
  // Flip item ${dir}ly
9412
9649
  (function() {
9413
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9414
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
9650
+ const entry = app.itemRegistry.get(${ID});
9651
+ if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
9415
9652
  ${dir === 'horizontal' ? 'entry.item.scale(-1, 1);' : 'entry.item.scale(1, -1);'}
9416
- return { success: true, action: 'flip', itemId: '${input.itemId}', direction: '${dir}' };
9653
+ return { success: true, action: 'flip', itemId: ${ID}, direction: ${JSON.stringify(dir)} };
9417
9654
  })();`.trim();
9418
9655
  }
9419
9656
  case 'reorder': {
@@ -9431,20 +9668,20 @@ ${resolveSource}
9431
9668
  return `
9432
9669
  // Reorder item: ${order}
9433
9670
  (function() {
9434
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9435
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
9671
+ const entry = app.itemRegistry.get(${ID});
9672
+ if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
9436
9673
  const sibling = ${siblingExpr};
9437
9674
  if (sibling) entry.item.${method}(sibling);
9438
- return { success: true, action: 'reorder', itemId: '${input.itemId}', order: '${order}' };
9675
+ return { success: true, action: 'reorder', itemId: ${ID}, order: ${JSON.stringify(order)} };
9439
9676
  })();`.trim();
9440
9677
  }
9441
9678
  return `
9442
9679
  // Reorder item: ${order}
9443
9680
  (function() {
9444
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9445
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
9681
+ const entry = app.itemRegistry.get(${ID});
9682
+ if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
9446
9683
  entry.item.${method}();
9447
- return { success: true, action: 'reorder', itemId: '${input.itemId}', order: '${order}' };
9684
+ return { success: true, action: 'reorder', itemId: ${ID}, order: ${JSON.stringify(order)} };
9448
9685
  })();`.trim();
9449
9686
  }
9450
9687
  default:
@@ -10231,7 +10468,7 @@ ${resolve}
10231
10468
  // HAND-DRAWN SKETCHY ANIMATION & JITTER
10232
10469
  // ===========================================================================
10233
10470
  generateHandDrawn(input) {
10234
- const S = (v) => JSON.stringify(v);
10471
+ const S = jsLiteral;
10235
10472
  // style carries no schema default: with one, `mode` (its alias) was never read.
10236
10473
  const requested = input.style ?? input.mode ?? 'sketchy';
10237
10474
  const style = requested === 'ink_reveal' ? 'reveal' : requested;
@@ -10620,7 +10857,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10620
10857
  const __esc = function(v) { return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); };
10621
10858
  let __html = String(result.html || '');
10622
10859
  const __applied = [];
10623
- ${input.lang || input.dir ? `__html = __html.replace(/<html\\b[^>]*>/i, function() { __applied.push('html'); return '<html lang="' + __esc(${JSON.stringify(input.lang ?? 'en')}) + '"' + ${JSON.stringify(input.dir ? ` dir="${input.dir}"` : '')} + '>'; });` : ''}
10860
+ ${input.lang || input.dir ? `__html = __html.replace(/<html\\b[^>]*>/i, function() { __applied.push('html'); return '<html lang="' + __esc(${jsLiteral(input.lang ?? 'en')}) + '"' + ${jsLiteral(input.dir ? ` dir="${input.dir}"` : '')} + '>'; });` : ''}
10624
10861
  ${input.alt ? `// The engine's widget draws its canvas from script, so there is no <canvas>
10625
10862
  // tag to label (prod retest): its accessible name is div#w's aria-label,
10626
10863
  // mirrored in the screen-reader text #w-desc. Both carry the alt; a literal
@@ -10649,7 +10886,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10649
10886
  return `(function() { return { error: 'data is required for create' }; })();`;
10650
10887
  const fields = resolveChartFields(input.chartType, input.data, input.options || {});
10651
10888
  if ('error' in fields)
10652
- return `(function() { return { success: false, error: ${JSON.stringify(fields.error)} }; })();`;
10889
+ return `(function() { return { success: false, error: ${jsLiteral(fields.error)} }; })();`;
10653
10890
  const dataStr = JSON.stringify(input.data);
10654
10891
  const optsStr = JSON.stringify(fields.options);
10655
10892
  return `
@@ -11135,8 +11372,8 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11135
11372
  if (app.historyManager) app.historyManager.saveState();
11136
11373
  const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
11137
11374
  // itemId too (round 8 BB, 1.70): every other create answers itemId, and a
11138
- // group is an item every other tool addresses by that id. In a batch this
11139
- // does not claim a $N slot — only create ops do.
11375
+ // group is an item every other tool addresses by that id. In a batch it
11376
+ // claims the next $N slot, as a create does (O2).
11140
11377
  return { success: true, groupId: groupId, itemId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
11141
11378
  }
11142
11379
  /**
@@ -11443,6 +11680,14 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11443
11680
  canvas: input.canvas,
11444
11681
  defaultGeometry: input.defaultGeometry,
11445
11682
  }).compile(input.doc);
11683
+ // A fire_if the doc declares is checked before anything is built: refused, the whole document is, with every reason.
11684
+ const specProblems = ops
11685
+ .filter((o) => o.op === 'addRelation')
11686
+ .map((o) => { const r = o; const why = relationSpecProblem(r.relation, r.params); return why ? `${r.from} -> ${r.to} (${r.relation}): ${why}` : null; })
11687
+ .filter((x) => !!x);
11688
+ if (specProblems.length) {
11689
+ return `(function() { return { success: false, error: ${jsLiteral('nothing was built: ' + specProblems.join(' | '))} }; })();`;
11690
+ }
11446
11691
  const opsJson = JSON.stringify(ops, null, 2);
11447
11692
  const diagJson = JSON.stringify(diagnostics);
11448
11693
  const docJson = JSON.stringify(input.doc);
@@ -12466,7 +12711,7 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12466
12711
  }
12467
12712
  /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
12468
12713
  generateEmitter(raw) {
12469
- const S = (v) => JSON.stringify(v);
12714
+ const S = jsLiteral;
12470
12715
  const normalized = normalizeEmitterSpec(raw.spec, raw.action !== 'set');
12471
12716
  const input = { ...raw, spec: normalized.spec };
12472
12717
  // A COUNT OVER THE CAP IS SAID (B144). The cap is 10000 a burst on the