@pinepaper.studio/mcp-server 1.6.23 → 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 (189) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +212 -17
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/SECURITY.md +46 -0
  11. package/dist/agent/types.d.ts +114 -106
  12. package/dist/agent/types.d.ts.map +1 -1
  13. package/dist/agent/types.js +1 -1
  14. package/dist/agent/types.js.map +1 -1
  15. package/dist/browser/puppeteer-controller.d.ts +6 -0
  16. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  17. package/dist/browser/puppeteer-controller.js +99 -71
  18. package/dist/browser/puppeteer-controller.js.map +1 -1
  19. package/dist/build-info.json +2 -2
  20. package/dist/cli.js +7 -5
  21. package/dist/cli.js.map +1 -1
  22. package/dist/design/design-systems.js +2 -2
  23. package/dist/design/design-systems.js.map +1 -1
  24. package/dist/i18n/locales/en.js +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +63 -12
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/index.d.ts +5 -0
  30. package/dist/mcp/index.d.ts.map +1 -0
  31. package/dist/mcp/index.js +5 -0
  32. package/dist/mcp/index.js.map +1 -0
  33. package/dist/mcp/server.d.ts +86 -0
  34. package/dist/mcp/server.d.ts.map +1 -0
  35. package/dist/mcp/server.js +159 -0
  36. package/dist/mcp/server.js.map +1 -0
  37. package/dist/mcp/stdio.d.ts +20 -0
  38. package/dist/mcp/stdio.d.ts.map +1 -0
  39. package/dist/mcp/stdio.js +82 -0
  40. package/dist/mcp/stdio.js.map +1 -0
  41. package/dist/mcp/types.d.ts +598 -0
  42. package/dist/mcp/types.d.ts.map +1 -0
  43. package/dist/mcp/types.js +140 -0
  44. package/dist/mcp/types.js.map +1 -0
  45. package/dist/metrics/performance-tracker.d.ts.map +1 -1
  46. package/dist/metrics/performance-tracker.js +4 -3
  47. package/dist/metrics/performance-tracker.js.map +1 -1
  48. package/dist/ontology/hints.d.ts.map +1 -1
  49. package/dist/ontology/hints.js +8 -2
  50. package/dist/ontology/hints.js.map +1 -1
  51. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  52. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -1
  53. package/dist/ontology/upstream-vocabulary.js +8 -2
  54. package/dist/ontology/upstream-vocabulary.js.map +1 -1
  55. package/dist/ontology/vocabulary.d.ts.map +1 -1
  56. package/dist/ontology/vocabulary.js +1 -0
  57. package/dist/ontology/vocabulary.js.map +1 -1
  58. package/dist/server/index.d.ts +1 -1
  59. package/dist/server/index.d.ts.map +1 -1
  60. package/dist/server/index.js +1 -1
  61. package/dist/server/index.js.map +1 -1
  62. package/dist/tools/batch-macros.d.ts +27 -0
  63. package/dist/tools/batch-macros.d.ts.map +1 -0
  64. package/dist/tools/batch-macros.js +158 -0
  65. package/dist/tools/batch-macros.js.map +1 -0
  66. package/dist/tools/choreograph.d.ts.map +1 -1
  67. package/dist/tools/choreograph.js +2 -0
  68. package/dist/tools/choreograph.js.map +1 -1
  69. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  70. package/dist/tools/compact-descriptions.js +8 -7
  71. package/dist/tools/compact-descriptions.js.map +1 -1
  72. package/dist/tools/craft-tools.d.ts +1 -1
  73. package/dist/tools/craft-tools.d.ts.map +1 -1
  74. package/dist/tools/create-known-keys.d.ts +2 -0
  75. package/dist/tools/create-known-keys.d.ts.map +1 -0
  76. package/dist/tools/create-known-keys.js +24 -0
  77. package/dist/tools/create-known-keys.js.map +1 -0
  78. package/dist/tools/definitions.d.ts +3 -1
  79. package/dist/tools/definitions.d.ts.map +1 -1
  80. package/dist/tools/definitions.js +122 -19
  81. package/dist/tools/definitions.js.map +1 -1
  82. package/dist/tools/effect-check.d.ts +1 -1
  83. package/dist/tools/effect-check.d.ts.map +1 -1
  84. package/dist/tools/effect-check.js +4 -0
  85. package/dist/tools/effect-check.js.map +1 -1
  86. package/dist/tools/engine-lists.d.ts +5 -2
  87. package/dist/tools/engine-lists.d.ts.map +1 -1
  88. package/dist/tools/engine-lists.js +10 -2
  89. package/dist/tools/engine-lists.js.map +1 -1
  90. package/dist/tools/engine-methods.d.ts.map +1 -1
  91. package/dist/tools/engine-methods.js +2 -0
  92. package/dist/tools/engine-methods.js.map +1 -1
  93. package/dist/tools/engine-surface.d.ts +1 -1
  94. package/dist/tools/engine-surface.d.ts.map +1 -1
  95. package/dist/tools/engine-surface.js +67 -6
  96. package/dist/tools/engine-surface.js.map +1 -1
  97. package/dist/tools/export-file-check.d.ts +31 -0
  98. package/dist/tools/export-file-check.d.ts.map +1 -0
  99. package/dist/tools/export-file-check.js +103 -0
  100. package/dist/tools/export-file-check.js.map +1 -0
  101. package/dist/tools/export-file-receipt.d.ts +68 -0
  102. package/dist/tools/export-file-receipt.d.ts.map +1 -0
  103. package/dist/tools/export-file-receipt.js +171 -0
  104. package/dist/tools/export-file-receipt.js.map +1 -0
  105. package/dist/tools/handlers/beats.d.ts +8 -45
  106. package/dist/tools/handlers/beats.d.ts.map +1 -1
  107. package/dist/tools/handlers/camera.d.ts +1 -1
  108. package/dist/tools/handlers/camera.d.ts.map +1 -1
  109. package/dist/tools/handlers/export.d.ts +1 -1
  110. package/dist/tools/handlers/export.d.ts.map +1 -1
  111. package/dist/tools/handlers/export.js +34 -8
  112. package/dist/tools/handlers/export.js.map +1 -1
  113. package/dist/tools/handlers/font.d.ts +1 -1
  114. package/dist/tools/handlers/font.d.ts.map +1 -1
  115. package/dist/tools/handlers/font.js +8 -7
  116. package/dist/tools/handlers/font.js.map +1 -1
  117. package/dist/tools/handlers/generate.d.ts +42 -271
  118. package/dist/tools/handlers/generate.d.ts.map +1 -1
  119. package/dist/tools/handlers/generate.js +4 -3
  120. package/dist/tools/handlers/generate.js.map +1 -1
  121. package/dist/tools/handlers/maps.d.ts +1 -1
  122. package/dist/tools/handlers/maps.d.ts.map +1 -1
  123. package/dist/tools/handlers/ontology.d.ts +1 -1
  124. package/dist/tools/handlers/ontology.d.ts.map +1 -1
  125. package/dist/tools/handlers/tool-guide.d.ts +1 -1
  126. package/dist/tools/handlers/tool-guide.d.ts.map +1 -1
  127. package/dist/tools/handlers.d.ts +82 -4
  128. package/dist/tools/handlers.d.ts.map +1 -1
  129. package/dist/tools/handlers.js +366 -89
  130. package/dist/tools/handlers.js.map +1 -1
  131. package/dist/tools/lane.d.ts +39 -0
  132. package/dist/tools/lane.d.ts.map +1 -0
  133. package/dist/tools/lane.js +247 -0
  134. package/dist/tools/lane.js.map +1 -0
  135. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  136. package/dist/tools/minimal-descriptions.js +3 -2
  137. package/dist/tools/minimal-descriptions.js.map +1 -1
  138. package/dist/tools/nested-ignored.d.ts +16 -0
  139. package/dist/tools/nested-ignored.d.ts.map +1 -0
  140. package/dist/tools/nested-ignored.js +83 -0
  141. package/dist/tools/nested-ignored.js.map +1 -0
  142. package/dist/tools/p5-compat/p5-helpers.d.ts +1 -1
  143. package/dist/tools/p5-compat/p5-helpers.js +1 -1
  144. package/dist/tools/shape-params.d.ts +2 -2
  145. package/dist/tools/shape-params.d.ts.map +1 -1
  146. package/dist/tools/shape-params.js +15 -2
  147. package/dist/tools/shape-params.js.map +1 -1
  148. package/dist/tools/stick-vocabulary.d.ts +4 -4
  149. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  150. package/dist/tools/stick-vocabulary.js +8 -4
  151. package/dist/tools/stick-vocabulary.js.map +1 -1
  152. package/dist/tools/timeline.d.ts +136 -0
  153. package/dist/tools/timeline.d.ts.map +1 -0
  154. package/dist/tools/timeline.js +1041 -0
  155. package/dist/tools/timeline.js.map +1 -0
  156. package/dist/tools/toolkits.d.ts +22 -7
  157. package/dist/tools/toolkits.d.ts.map +1 -1
  158. package/dist/tools/toolkits.js +113 -21
  159. package/dist/tools/toolkits.js.map +1 -1
  160. package/dist/types/code-generator.d.ts +55 -2
  161. package/dist/types/code-generator.d.ts.map +1 -1
  162. package/dist/types/code-generator.js +671 -202
  163. package/dist/types/code-generator.js.map +1 -1
  164. package/dist/types/schemas.d.ts +5281 -25048
  165. package/dist/types/schemas.d.ts.map +1 -1
  166. package/dist/types/schemas.js +81 -41
  167. package/dist/types/schemas.js.map +1 -1
  168. package/dist/utils/asset-paths.d.ts.map +1 -1
  169. package/dist/utils/asset-paths.js +4 -4
  170. package/dist/utils/asset-paths.js.map +1 -1
  171. package/dist/utils/env.d.ts +9 -0
  172. package/dist/utils/env.d.ts.map +1 -0
  173. package/dist/utils/env.js +11 -0
  174. package/dist/utils/env.js.map +1 -0
  175. package/dist/utils/export-dir.d.ts +36 -0
  176. package/dist/utils/export-dir.d.ts.map +1 -0
  177. package/dist/utils/export-dir.js +86 -0
  178. package/dist/utils/export-dir.js.map +1 -0
  179. package/dist/utils/js-literal.d.ts +4 -0
  180. package/dist/utils/js-literal.d.ts.map +1 -0
  181. package/dist/utils/js-literal.js +25 -0
  182. package/dist/utils/js-literal.js.map +1 -0
  183. package/dist/utils/mcp-log.d.ts +1 -1
  184. package/dist/utils/mcp-log.d.ts.map +1 -1
  185. package/dist/version.d.ts.map +1 -1
  186. package/dist/version.js +5 -12
  187. package/dist/version.js.map +1 -1
  188. package/manifest.json +46 -63
  189. package/package.json +15 -12
@@ -5,11 +5,15 @@
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
7
  import { compileChoreography } from '../tools/choreograph.js';
8
+ import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
8
9
  import { normalizeSvgSize } from '../utils/svg-size.js';
9
- import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
10
+ import { WORLD3D_COLOR_PATHS, PATTERN_KINDS } from '../tools/engine-lists.js';
10
11
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
11
- 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';
12
15
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
16
+ import { expandBatchMacros } from '../tools/batch-macros.js';
13
17
  import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
14
18
  // Diagram types
15
19
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
@@ -26,7 +30,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
26
30
  // Custom relation/code types
27
31
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
28
32
  // Mask types
29
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
33
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, PatternInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
30
34
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
31
35
  // =============================================================================
32
36
  // HELPER FUNCTIONS
@@ -144,7 +148,16 @@ function generateBlendModeCode(itemVarName, blendMode) {
144
148
  /**
145
149
  * Template for creating items with support for gradients, shadows, and blend modes
146
150
  */
151
+ const UNSAFE_ID_CHARS = /['"`\\\n\r\u2028\u2029]/;
147
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
+ }
148
161
  // Colour objects are gradient specs: normalised, or refused by name.
149
162
  for (const key of ['color', 'fillColor', 'strokeColor']) {
150
163
  const v = properties[key];
@@ -211,6 +224,10 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
211
224
  // All three now mean no fill, applied after the item exists.
212
225
  const noFill = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
213
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;
214
231
  // `color` ON A CLOSED PATH IS ITS FILL.
215
232
  //
216
233
  // The engine's path branch paints `color` as the STROKE whenever no fillColor
@@ -309,7 +326,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
309
326
  const textOnly = (k) => TEXT_STYLE_KEYS.includes(k) || k === 'fit' || k === 'textDirection' || k === 'dir'
310
327
  || TABULAR_KEYS.includes(k) || k === 'strokePosition';
311
328
  const ignored = Object.keys(properties).filter((k) => itemType === 'text'
312
- ? !CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k)
329
+ ? (!CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k))
330
+ || (k === 'letterSpacing' && letterSpacingCss(properties[k]) === undefined)
313
331
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
314
332
  // Build the code
315
333
  let code = `
@@ -334,7 +352,7 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
334
352
  if (blendMode) {
335
353
  code += '\n' + generateBlendModeCode('item', blendMode);
336
354
  }
337
- if (clearFill)
355
+ if (clearFill || outlineOnly)
338
356
  code += `\nitem.fillColor = null;`;
339
357
  // DASHES, CAPS AND JOINS FOR EVERY SHAPE (round 7 Z, 1.54). create() applies
340
358
  // them only in its path / line / pattern branches; the shared tail sets a
@@ -398,15 +416,27 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
398
416
  code += `\nif (app.setDynamicContent) app.setDynamicContent(item, '${contentType}', ${JSON.stringify(opts)});`;
399
417
  }
400
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
+ }
401
433
  code += `
402
434
  const itemId = item.data.registryId;
403
435
  app.historyManager.saveState();
404
436
 
405
437
  // Return item info
406
- ({ 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
407
- ? `, 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. `
408
- + 'Check the spelling against this item type\'s documented properties.')}`
409
- : ''} });`;
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(' '))}` : ''} });`;
410
440
  // Loading a font is async, and it has to happen BEFORE create() measures the
411
441
  // text — so only this case becomes an async IIFE; every other create stays a
412
442
  // plain snippet ending in its (-led result.
@@ -434,24 +464,6 @@ const MODIFY_DID_YOU_MEAN = {
434
464
  scaling: 'scale', size: 'width / height', rotate: 'rotation', angle: 'rotation', shear: 'skewX / skewY', skew: 'skewX / skewY',
435
465
  alpha: 'opacity', text: 'content', stroke_width: 'strokeWidth', lineWidth: 'strokeWidth',
436
466
  };
437
- /**
438
- * Every `properties` key create_item can act on: what the engine's create()
439
- * and normalizeParams() read (generated — see shape-params.ts), plus the keys
440
- * generateCreateItemCode consumes itself before the engine sees them.
441
- */
442
- const CREATE_KNOWN_KEYS = new Set([
443
- ...ACCEPTED_CREATE_PARAMS,
444
- ...NORMALIZE_PARAM_READS,
445
- // withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
446
- // setDynamicContent for text.
447
- 'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
448
- // Lifetimes, on ANY item: this generator applies them through app.setLifetime
449
- // (emitLifetime), whatever create() itself reads.
450
- 'bornAt', 'ttl',
451
- // A solid fill's own alpha (FxTool 12 STROKEFILL); confirmed per call by
452
- // emitFillOpacityCheck, since an older studio ignores it.
453
- 'fillOpacity',
454
- ]);
455
467
  /**
456
468
  * Load a font family before text is drawn with it, and say if it still is not there.
457
469
  *
@@ -472,11 +484,11 @@ export function emitEnsureFont(family) {
472
484
  return `
473
485
  let __font = null;
474
486
  if (typeof app.checkFont === 'function') {
475
- const __v0 = app.checkFont(${JSON.stringify(family)});
487
+ const __v0 = app.checkFont(${jsLiteral(family)});
476
488
  if (__v0 && __v0.available === false) {
477
489
  if (typeof app.ensureFontsLoaded === 'function') { try { await app.ensureFontsLoaded('all'); } catch (_) { /* reported below */ } }
478
- try { if (typeof document !== 'undefined' && document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
479
- 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)});
480
492
  let __via = 'studio';
481
493
  // Not in the studio's own sheets (Monoton, a common neon face, is not):
482
494
  // the page's CSP allows fonts.googleapis.com stylesheets and
@@ -484,21 +496,21 @@ if (typeof app.checkFont === 'function') {
484
496
  if (!(__v1 && __v1.available === true) && typeof document !== 'undefined' && document.head) {
485
497
  __via = 'google-fonts';
486
498
  await new Promise(function(res) {
487
- const id = 'pp-gf-' + ${JSON.stringify(primary)}.replace(/[^a-z0-9]+/gi, '-');
499
+ const id = 'pp-gf-' + ${jsLiteral(primary)}.replace(/[^a-z0-9]+/gi, '-');
488
500
  if (document.getElementById(id)) return res();
489
501
  const l = document.createElement('link');
490
502
  l.id = id; l.rel = 'stylesheet';
491
- 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';
492
504
  l.onload = res; l.onerror = res; setTimeout(res, 5000);
493
505
  document.head.appendChild(l);
494
506
  });
495
- try { if (document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
496
- __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)});
497
509
  }
498
510
  __font = (__v1 && __v1.available === true)
499
- ? { family: ${JSON.stringify(primary)}, available: true, loadedNow: true, via: __via }
500
- : { family: ${JSON.stringify(primary)}, available: false,
501
- 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. '
502
514
  + ((__v1 && __v1.reason) || '') + ' font list_available names the families this studio can load.' };
503
515
  }
504
516
  }`;
@@ -569,13 +581,13 @@ function takeDirection(props) {
569
581
  function emitDirectionCheck(itemExpr, req) {
570
582
  if ('error' in req)
571
583
  return `
572
- const __direction = ${JSON.stringify({ applied: false, error: req.error })};`;
573
- const v = JSON.stringify(req.value);
584
+ const __direction = ${jsLiteral({ applied: false, error: req.error })};`;
585
+ const v = jsLiteral(req.value);
574
586
  return `
575
587
  const __direction = (function(it) {
576
588
  if (!it || it.className !== 'PointText') return { applied: false, note: 'direction applies to text items only.' };
577
589
  return it.data && it.data.direction === ${v}
578
- ? { applied: true, value: ${v}${req.resolved ? `, resolved: ${JSON.stringify(req.resolved)}` : ''} }
590
+ ? { applied: true, value: ${v}${req.resolved ? `, resolved: ${jsLiteral(req.resolved)}` : ''} }
579
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.' };
580
592
  })(${itemExpr});`;
581
593
  }
@@ -675,7 +687,7 @@ function takeStrokePosition(props, allowNull) {
675
687
  }
676
688
  function emitStrokePositionCheck(itemExpr, req) {
677
689
  if ('error' in req)
678
- return `\nconst __strokePos = ${JSON.stringify({ applied: false, error: req.error })};`;
690
+ return `\nconst __strokePos = ${jsLiteral({ applied: false, error: req.error })};`;
679
691
  if (req.value === 'center')
680
692
  return `\nconst __strokePos = { applied: true, value: 'center' };`;
681
693
  return `
@@ -693,7 +705,7 @@ function anchorHold(anchor) {
693
705
  function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
694
706
  return `
695
707
  if (typeof app.fitText === 'function') {
696
- const __fbox = ${JSON.stringify(box)};
708
+ const __fbox = ${jsLiteral(box)};
697
709
  if (__fbox.hold === undefined) { const __h = ${holdFallback}; if (__h) __fbox.hold = __h; }
698
710
  const __fr = app.fitText(${itemExpr}, __fbox);
699
711
  __textFit = __fr && __fr.ok !== false
@@ -734,13 +746,26 @@ function authoringFrameJs(name) {
734
746
  return (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || cs || { width: 1920, height: 1080 };
735
747
  })();`;
736
748
  }
737
- const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'textAlign', 'align', 'text'];
749
+ const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'letterSpacing', 'textAlign', 'align', 'text'];
750
+ /**
751
+ * letterSpacing as the studio draws it, or undefined if it cannot be. create()
752
+ * and modifyItem() do not read it (B108); the studio draws the item's own
753
+ * letterSpacing, a CSS length, as its keyframes set it, and a number is px.
754
+ */
755
+ function letterSpacingCss(v) {
756
+ if (typeof v === 'number' && Number.isFinite(v))
757
+ return `${v}px`;
758
+ if (typeof v === 'string' && /^-?\d*\.?\d+(px|em)?$/.test(v.trim()))
759
+ return /\d$/.test(v.trim()) ? `${v.trim()}px` : v.trim();
760
+ return undefined;
761
+ }
738
762
  function emitTextStyle(itemExpr, props) {
739
763
  const weight = props.fontWeight;
740
764
  const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
741
765
  const lh = typeof props.lineHeight === 'number' ? props.lineHeight : undefined;
742
766
  const leading = typeof props.leading === 'number' ? props.leading : undefined;
743
- if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined)
767
+ const ls = letterSpacingCss(props.letterSpacing);
768
+ if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined && ls === undefined)
744
769
  return '';
745
770
  return `
746
771
  (function(it) {
@@ -749,10 +774,11 @@ function emitTextStyle(itemExpr, props) {
749
774
  const edge = function(b) { return j === 'left' ? b.left : j === 'right' ? b.right : b.center.x; };
750
775
  const b0 = it.bounds, ax = edge(b0), ay = b0.center.y;
751
776
  ${weight !== undefined || props.fontStyle !== undefined
752
- ? `it.fontWeight = ${JSON.stringify(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
777
+ ? `it.fontWeight = ${jsLiteral(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
753
778
  : ''}
754
779
  ${leading !== undefined ? `it.leading = ${leading};` : ''}
755
780
  ${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
781
+ ${ls !== undefined ? `it.letterSpacing = ${jsLiteral(ls)};` : ''}
756
782
  const b1 = it.bounds;
757
783
  it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
758
784
  })(${itemExpr});`;
@@ -901,6 +927,9 @@ function emitLifetime(itemExpr, bornAt, ttl) {
901
927
  * Template for modifying items
902
928
  */
903
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);
904
933
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
905
934
  // Aliases for text items: text -> content (B66), textAlign / align -> justification (B89).
906
935
  // A key consumed here was applied, so the unread-key report below must not
@@ -944,23 +973,23 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
944
973
  // The warning the engine logs on that path is console.warn, which production
945
974
  // strips, so the failure was invisible twice over.
946
975
  let code = `
947
- // Modify item ${itemId}
976
+ // Modify item ${ID}
948
977
  let _modified = true;`;
949
978
  if (Object.keys(restProperties).length > 0) {
950
979
  code += `
951
- _modified = app.modifyItem('${itemId}', ${JSON.stringify(restProperties, null, 2)}) !== false;
952
- 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' }; }`;
953
982
  }
954
983
  else {
955
984
  code += `
956
- if (!app.getItemById('${itemId}')) { return { success: false, error: 'no item ${itemId}' }; }`;
985
+ if (!app.getItemById(${ID})) { return { success: false, error: 'no item ' + ${ID} }; }`;
957
986
  }
958
987
  // Handle dynamic content type changes
959
988
  if (contentType !== undefined) {
960
989
  if (contentType === null || contentType === 'none' || contentType === '') {
961
990
  // Remove dynamic content
962
991
  code += `
963
- const entry = app.itemRegistry.get('${itemId}');
992
+ const entry = app.itemRegistry.get(${ID});
964
993
  if (entry && entry.item && app.removeDynamicContent) app.removeDynamicContent(entry.item);`;
965
994
  }
966
995
  else {
@@ -973,8 +1002,8 @@ if (entry && entry.item && app.removeDynamicContent) app.removeDynamicContent(en
973
1002
  if (countdownEndText)
974
1003
  opts.countdownEndText = countdownEndText;
975
1004
  code += `
976
- const entry = app.itemRegistry.get('${itemId}');
977
- 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)});`;
978
1007
  }
979
1008
  }
980
1009
  // AN AUDIO ITEM'S LEVEL IS NOT AN ITEM PROPERTY. modifyItem applies changes
@@ -986,13 +1015,13 @@ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.it
986
1015
  if (levelKey) {
987
1016
  const level = Math.max(0, Math.min(1, properties[levelKey]));
988
1017
  code += `
989
- const _ae = app.itemRegistry.get('${itemId}');
1018
+ const _ae = app.itemRegistry.get(${ID});
990
1019
  if (_ae && _ae.type === 'audio') {
991
1020
  _ae.properties = _ae.properties || {};
992
1021
  _ae.properties.audioGain = ${level};
993
1022
  _ae.properties.gain = ${level};
994
1023
  const _A = (typeof window !== 'undefined') && window.PinePaperAgent;
995
- 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;
996
1025
  if (_m && app.audioLayer && typeof app.audioLayer.setVolume === 'function') app.audioLayer.setVolume(${level}, _m.id);
997
1026
  }`;
998
1027
  }
@@ -1000,17 +1029,17 @@ if (_ae && _ae.type === 'audio') {
1000
1029
  // does not pass on; applied to a raster, or to the raster an image group holds.
1001
1030
  if (typeof properties.smoothing === 'string') {
1002
1031
  code += `
1003
- const _sm = app.itemRegistry.get('${itemId}');
1032
+ const _sm = app.itemRegistry.get(${ID});
1004
1033
  const _smR = _sm && _sm.item && (_sm.item.className === 'Raster' ? _sm.item
1005
1034
  : (typeof _sm.item.getItem === 'function' ? _sm.item.getItem({ className: 'Raster' }) : null));
1006
- if (_smR) _smR.smoothing = ${JSON.stringify(properties.smoothing)};`;
1035
+ if (_smR) _smR.smoothing = ${jsLiteral(properties.smoothing)};`;
1007
1036
  }
1008
1037
  // No fill on modify too: null / 'transparent' / 'none' clear it outright,
1009
1038
  // after modifyItem, which would otherwise read 'transparent' as black.
1010
1039
  const fillNone = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
1011
1040
  if (fillNone(properties.fillColor) || (properties.fillColor === undefined && properties.color !== undefined && fillNone(properties.color))) {
1012
1041
  code += `
1013
- const _nf = app.itemRegistry.get('${itemId}');
1042
+ const _nf = app.itemRegistry.get(${ID});
1014
1043
  if (_nf && _nf.item) _nf.item.fillColor = null;`;
1015
1044
  }
1016
1045
  // SKEW AND AFFINE, FOR MOCKUPS (round 9 HH). A box face or a phone screen in
@@ -1026,7 +1055,7 @@ if (_nf && _nf.item) _nf.item.fillColor = null;`;
1026
1055
  const affine = skewX !== 0 || skewY !== 0 || matrix !== null;
1027
1056
  if (affine) {
1028
1057
  code += `
1029
- const _af = app.itemRegistry.get('${itemId}');
1058
+ const _af = app.itemRegistry.get(${ID});
1030
1059
  const _afi = _af && _af.item;
1031
1060
  if (_afi) {
1032
1061
  ${skewX !== 0 || skewY !== 0 ? `_afi.shear(Math.tan(${skewX} * Math.PI / 180), Math.tan(${skewY} * Math.PI / 180), _afi.bounds.center);` : ''}
@@ -1037,30 +1066,32 @@ if (_afi) {
1037
1066
  if (modFit) {
1038
1067
  code += `
1039
1068
  let __textFit = null;
1040
- 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)")}`;
1041
1070
  }
1042
1071
  const textStyle = emitTextStyle('_ts && _ts.item', properties);
1043
1072
  if (textStyle) {
1044
1073
  code += `
1045
- const _ts = app.itemRegistry.get('${itemId}');${textStyle}`;
1074
+ const _ts = app.itemRegistry.get(${ID});${textStyle}`;
1046
1075
  }
1047
1076
  const modLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
1048
1077
  if (modLifetime) {
1049
1078
  code += `
1050
1079
  let __lifetime = null;
1051
- 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)}`;
1052
1081
  }
1053
1082
  if (dataFlags && Object.keys(dataFlags).length > 0) {
1054
1083
  code += `
1055
- const _flagged = app.itemRegistry.get('${itemId}');
1056
- 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)});`;
1057
1086
  }
1058
1087
  const modFont = typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
1059
1088
  // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
1060
1089
  // raster answered success and left it 16 px; the engine reads `scale`. Same
1061
1090
  // report create_item gives, against the engine's modify path (generated).
1091
+ if (textStyle && letterSpacingCss(properties.letterSpacing) !== undefined)
1092
+ aliased.add('letterSpacing');
1062
1093
  const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
1063
- 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.`
1064
1095
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
1065
1096
  code += `
1066
1097
  app.historyManager.saveState();
@@ -1068,11 +1099,11 @@ ${properties.content !== undefined ? `
1068
1099
  // CONTENT UNDER A CONTENT TRACK (round 10, 10.13): the keyframes set the text
1069
1100
  // on every frame, so a direct edit is overwritten at once — it answered
1070
1101
  // success and changed nothing visible.
1071
- const __ctItem = app.getItemById('${itemId}');
1102
+ const __ctItem = app.getItemById(${ID});
1072
1103
  const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
1073
1104
  ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
1074
- ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modFillOpacity ? emitFillOpacityCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
1075
- 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} };`;
1076
1107
  // Wrapped, because the body now RETURNS early on a miss rather than falling
1077
1108
  // through to an unconditional success. Async only when a font must load
1078
1109
  // before modifyItem re-measures the text.
@@ -1085,8 +1116,9 @@ return { success: true, itemId: '${itemId}'${properties.content !== undefined ?
1085
1116
  * Template for deleting items
1086
1117
  */
1087
1118
  function generateDeleteItemCode(itemId) {
1119
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1088
1120
  return `
1089
- // Delete item ${itemId}
1121
+ // Delete item ${ID}
1090
1122
  //
1091
1123
  // Delegate to app.deleteItem rather than hand-rolling the removal. The old
1092
1124
  // inline version called \`app.itemRegistry.remove(...)\`, which does not exist —
@@ -1103,11 +1135,11 @@ function generateDeleteItemCode(itemId) {
1103
1135
  if (typeof app.deleteItem !== 'function') {
1104
1136
  return { success: false, error: 'app.deleteItem unavailable — update FxTool' };
1105
1137
  }
1106
- const ok = app.deleteItem('${itemId}');
1138
+ const ok = app.deleteItem(${ID});
1107
1139
  if (!ok) {
1108
- return { success: false, error: 'Item not found: ${itemId}' };
1140
+ return { success: false, error: 'Item not found: ' + ${ID} };
1109
1141
  }
1110
- return { success: true, itemId: '${itemId}' };
1142
+ return { success: true, itemId: ${ID} };
1111
1143
  })();
1112
1144
  `.trim();
1113
1145
  }
@@ -1123,6 +1155,92 @@ function generateDeleteItemCode(itemId) {
1123
1155
  export function relationRefusalReasonJs(typeJs) {
1124
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; })()`;
1125
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
+ }
1126
1244
  function generateAddRelationCode(sourceId, targetId, relationType, params) {
1127
1245
  // AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
1128
1246
  // stores a relation to an id it cannot resolve, returns true, and says "it
@@ -1143,7 +1261,7 @@ function generateAddRelationCode(sourceId, targetId, relationType, params) {
1143
1261
  }
1144
1262
  const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
1145
1263
  if (!success) {
1146
- const __why = ${relationRefusalReasonJs(JSON.stringify(relationType))};
1264
+ const __why = ${relationRefusalReasonJs(jsLiteral(relationType))};
1147
1265
  return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}' + (__why ? ': ' + __why : '.') };
1148
1266
  }
1149
1267
  app.historyManager.saveState();
@@ -1191,6 +1309,98 @@ export function emitterCountWarnings(spec) {
1191
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.`
1192
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.`);
1193
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
+ */
1320
+ /**
1321
+ * An emitter spec as the studio reads it, and what the reply says about it.
1322
+ *
1323
+ * The studio reads velocities and offsets as [x, y] pairs only and drops any
1324
+ * other entry, so velocities: [{x, y}] made particles with no velocity and no
1325
+ * word (VELOC23). Point objects are turned into pairs here. And a burst draws
1326
+ * `count` particles, the i-th taking velocities[i % length]: with fewer
1327
+ * velocities than particles the extras repeat a velocity and draw on top of an
1328
+ * earlier particle, and with more the extras go unused, both in silence
1329
+ * (VELOCMISMATCH23). That is said, not changed: cycling is the studio's rule.
1330
+ * `assumeDefaultCount` is false for a set, whose spec is only the keys changed.
1331
+ */
1332
+ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1333
+ const warnings = [];
1334
+ if (!spec || typeof spec !== 'object')
1335
+ return { spec, warnings };
1336
+ const pairs = (v) => (Array.isArray(v)
1337
+ ? v.map((p) => (p && typeof p === 'object' && !Array.isArray(p) && ('x' in p || 'y' in p)
1338
+ ? [Number(p.x ?? 0), Number(p.y ?? 0)]
1339
+ : p))
1340
+ : v);
1341
+ const fix = (o) => {
1342
+ const out = { ...o };
1343
+ for (const k of ['velocities', 'offsets'])
1344
+ if (out[k] !== undefined)
1345
+ out[k] = pairs(out[k]);
1346
+ return out;
1347
+ };
1348
+ const out = fix(spec);
1349
+ if (Array.isArray(spec.bursts))
1350
+ out.bursts = spec.bursts.map((b) => (b && typeof b === 'object' ? fix(b) : b));
1351
+ // The studio's default count per burst (ParticleEmitter DEFAULT_LOOK).
1352
+ const DEFAULT_COUNT = 12;
1353
+ const mismatch = (where, count, velocities) => {
1354
+ if (!Array.isArray(velocities) || !velocities.length)
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;
1359
+ const n = typeof count === 'number' && Number.isFinite(count) ? Math.round(count) : (assumeDefaultCount ? DEFAULT_COUNT : null);
1360
+ if (n === null || n === velocities.length)
1361
+ return;
1362
+ warnings.push(n > velocities.length
1363
+ ? `${where}: count ${n} with ${velocities.length} velocities: particle i takes velocities[i % ${velocities.length}], so ${n - velocities.length} particles repeat an earlier particle's path and draw on top of it. Give one velocity per particle, or set count to ${velocities.length}.`
1364
+ : `${where}: count ${n} with ${velocities.length} velocities: only the first ${n} velocities are used. Set count to ${velocities.length} to draw them all.`);
1365
+ };
1366
+ // The spec's own pair once; a burst only where it sets either itself (it inherits the rest).
1367
+ const bursts = Array.isArray(out.bursts) ? out.bursts : [];
1368
+ const inherited = bursts.some((b) => !b || typeof b !== 'object' || (b.count === undefined && b.velocities === undefined));
1369
+ if (out.velocities !== undefined && (out.count !== undefined || inherited))
1370
+ mismatch('spec', out.count, out.velocities);
1371
+ bursts.forEach((b, i) => {
1372
+ const own = (b && typeof b === 'object' ? b : {});
1373
+ if (own.count !== undefined || own.velocities !== undefined)
1374
+ mismatch(`bursts[${i}]`, own.count ?? out.count, own.velocities ?? out.velocities);
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); });
1402
+ return { spec: out, warnings };
1403
+ }
1194
1404
  /**
1195
1405
  * moves_along_path, from the parameters a caller actually writes.
1196
1406
  *
@@ -1341,15 +1551,16 @@ app.historyManager.saveState();
1341
1551
  * Template for querying relations
1342
1552
  */
1343
1553
  function generateQueryRelationsCode(itemId, relationType, direction = 'outgoing') {
1554
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1344
1555
  if (direction === 'outgoing') {
1345
1556
  const typeArg = relationType ? `'${relationType}'` : 'undefined';
1346
1557
  return `
1347
- // Query outgoing relations for ${itemId}
1348
- const relations = app.getRelations('${itemId}', ${typeArg});
1558
+ // Query outgoing relations for ${ID}
1559
+ const relations = app.getRelations(${ID}, ${typeArg});
1349
1560
 
1350
1561
  // Format results
1351
1562
  const formatted = relations.map(r => ({
1352
- sourceId: r.sourceId || '${itemId}',
1563
+ sourceId: r.sourceId || ${ID},
1353
1564
  // RelationRegistry.getAssociations returns { relation, target, params } —
1354
1565
  // there is no \`targetId\` on it. Reading the wrong field made EVERY edge come
1355
1566
  // back with an undefined destination, so the graph was unreadable through
@@ -1365,13 +1576,13 @@ const formatted = relations.map(r => ({
1365
1576
  else {
1366
1577
  const typeArg = relationType ? `'${relationType}'` : 'undefined';
1367
1578
  return `
1368
- // Query incoming relations for ${itemId}
1369
- const results = app.queryByRelationTarget('${itemId}', ${typeArg});
1579
+ // Query incoming relations for ${ID}
1580
+ const results = app.queryByRelationTarget(${ID}, ${typeArg});
1370
1581
 
1371
1582
  // Format results
1372
1583
  const relations = results.map(r => ({
1373
1584
  sourceId: r.itemId,
1374
- targetId: '${itemId}',
1585
+ targetId: ${ID},
1375
1586
  // queryByTarget returns the real relation name on each result. Hardcoding
1376
1587
  // the filter string labelled every edge 'unknown' on an unfiltered query —
1377
1588
  // which is exactly the query you make when exploring an unfamiliar graph.
@@ -1387,13 +1598,14 @@ const relations = results.map(r => ({
1387
1598
  * Template for simple animation
1388
1599
  */
1389
1600
  function generateAnimateCode(itemId, animationType, speed, intensity, delay) {
1601
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1390
1602
  const extra = (intensity !== undefined ? `,\n animationIntensity: ${intensity}` : '') +
1391
1603
  (delay !== undefined ? `,\n animationDelay: ${delay}` : '');
1392
1604
  return `
1393
- // Apply ${animationType} animation to ${itemId}
1394
- const item = app.getItemById('${itemId}');
1605
+ // Apply ${animationType} animation to ${ID}
1606
+ const item = app.getItemById(${ID});
1395
1607
  if (!item) {
1396
- throw new Error('Item not found: ${itemId}');
1608
+ throw new Error('Item not found: ' + ${ID});
1397
1609
  }
1398
1610
  app.animate(item, {
1399
1611
  animationType: '${animationType}',
@@ -1409,13 +1621,13 @@ app.animate(item, {
1409
1621
  if (r) {
1410
1622
  return {
1411
1623
  success: false,
1412
- itemId: '${itemId}',
1624
+ itemId: ${ID},
1413
1625
  error: 'Unknown animationType "' + r.requested + '" — nothing will move.',
1414
1626
  requested: r.requested,
1415
1627
  known: r.known,
1416
1628
  };
1417
1629
  }
1418
- return { success: true, itemId: '${itemId}', animationType: '${animationType}' };
1630
+ return { success: true, itemId: ${ID}, animationType: '${animationType}' };
1419
1631
  })();
1420
1632
  `.trim();
1421
1633
  }
@@ -1465,10 +1677,11 @@ function normalizeKeyframePositions(keyframes) {
1465
1677
  * "item_7 is not on the canvas" is actionable, silence is not.
1466
1678
  */
1467
1679
  function requireItem(itemId, what) {
1680
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1468
1681
  const isRef = typeof itemId === 'string' && itemId.startsWith('$');
1469
1682
  const targetExpr = isRef
1470
- ? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || '${itemId}')`
1471
- : `'${itemId}'`;
1683
+ ? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || ${ID})`
1684
+ : `${ID}`;
1472
1685
  return `
1473
1686
  const _targetId = ${targetExpr};
1474
1687
  const _target = (app.getItemById && app.getItemById(_targetId))
@@ -1506,6 +1719,7 @@ function unanimatedTextKeysJs(keys) {
1506
1719
  /** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
1507
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.'`;
1508
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
1509
1723
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1510
1724
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1511
1725
  const opts = { duration: calculatedDuration, loop };
@@ -1587,9 +1801,9 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1587
1801
  const easingAtCheck = easingAt !== undefined ? `
1588
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.');` : '';
1589
1803
  const runtimeWarning = `${textKeysJs ? ', ...(__ignored.length ? { ignoredProperties: __ignored } : {})' : ''}, ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
1590
- const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
1804
+ const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `${ID}`;
1591
1805
  return `
1592
- // Apply keyframe animation to ${itemId}
1806
+ // Apply keyframe animation to ${ID}
1593
1807
  (function() {
1594
1808
  ${requireItem(itemId, 'the animation')}${audioLevelGuard}
1595
1809
  // The track as it was, so the result can say what happened to it: merged
@@ -1689,15 +1903,16 @@ function emitGeneratorRun(generatorName, paramsJson, region) {
1689
1903
  * Template for applying effects
1690
1904
  */
1691
1905
  function generateApplyEffectCode(itemId, effectType, params) {
1906
+ const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1692
1907
  return `
1693
- // Apply ${effectType} effect to ${itemId}
1694
- const item = app.getItemById('${itemId}');
1908
+ // Apply ${effectType} effect to ${ID}
1909
+ const item = app.getItemById(${ID});
1695
1910
  if (!item) {
1696
- throw new Error('Item not found: ${itemId}');
1911
+ throw new Error('Item not found: ' + ${ID});
1697
1912
  }
1698
1913
  app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
1699
1914
 
1700
- ({ success: true, itemId: '${itemId}', effectType: '${effectType}' });
1915
+ ({ success: true, itemId: ${ID}, effectType: '${effectType}' });
1701
1916
  `.trim();
1702
1917
  }
1703
1918
  /**
@@ -2008,6 +2223,25 @@ const MEDIA_PURGE = `
2008
2223
  if (__PA && typeof __PA.listMedia === 'function' && typeof __PA.removeMedia === 'function') {
2009
2224
  __PA.listMedia().forEach(function(m) { try { if (__PA.removeMedia(m.id)) __mediaRemoved++; } catch (_) { /* keep clearing */ } });
2010
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 = [];`;
2011
2245
  /**
2012
2246
  * Template for clearing all items from canvas
2013
2247
  */
@@ -2031,6 +2265,7 @@ if (typeof app.clearCanvas === 'function') {
2031
2265
  if (app.itemRegistry) app.itemRegistry.clear();
2032
2266
  if (app.relationRegistry) app.relationRegistry.clear();
2033
2267
  }
2268
+ ${ID_COUNTER_RESET}
2034
2269
 
2035
2270
  if (app.historyManager) app.historyManager.saveState();
2036
2271
 
@@ -2044,9 +2279,11 @@ const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
2044
2279
  */
2045
2280
  function generateExportSVGCode() {
2046
2281
  return `
2047
- // Export animated SVG
2282
+ // Export animated SVG. An export that produced nothing is a failure, named, not a success with an empty string.
2048
2283
  const svgString = app.exportAnimatedSVG();
2049
- ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
2284
+ (typeof svgString !== 'string' || svgString.length === 0)
2285
+ ? ({ success: false, step: 'export', error: 'export failed at the export step: exportAnimatedSVG returned ' + (svgString === '' ? 'an empty string' : (svgString === null ? 'null' : typeof svgString)) + ', so there is no SVG to return' })
2286
+ : ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
2050
2287
  `.trim();
2051
2288
  }
2052
2289
  /**
@@ -2279,13 +2516,23 @@ export function planKeyframeMerges(operations) {
2279
2516
  * through modifyItem after create, so the engine's own bookkeeping runs.
2280
2517
  */
2281
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.';
2282
2528
  function withBrandDefaults(code, itemType, props) {
2283
2529
  if (props.noBrand === true)
2284
2530
  return code;
2285
2531
  const isText = itemType === 'text';
2286
2532
  if (!isText && !BRAND_FILL_TYPES.has(itemType))
2287
2533
  return code;
2288
- 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);
2289
2536
  const hasFont = props.fontFamily !== undefined;
2290
2537
  if (hasColor && (!isText || hasFont))
2291
2538
  return code;
@@ -2374,6 +2621,15 @@ ${itemSettersApplyJs(s, '__sid', '__res')}
2374
2621
  return __res;
2375
2622
  })();`;
2376
2623
  }
2624
+ /**
2625
+ * A batch op's item reference as a JS expression: `$N` is the Nth item this
2626
+ * batch created, anything else is an id, quoted. Only digits follow the `$` —
2627
+ * the raw splice it replaces put `$1);…;(` straight into the snippet, and an id
2628
+ * with a quote in it broke the batch's syntax.
2629
+ */
2630
+ export function batchRef(raw) {
2631
+ return typeof raw === 'string' && /^\$\d+$/.test(raw) ? `__ref(${raw.slice(1)})` : JSON.stringify(String(raw));
2632
+ }
2377
2633
  function asReturningBody(snippet) {
2378
2634
  const single = snippet.trim();
2379
2635
  if (single.startsWith('(async function()'))
@@ -2878,6 +3134,11 @@ export class PinePaperCodeGenerator {
2878
3134
  return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
2879
3135
  })();`.trim();
2880
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
+ }
2881
3142
  if (validated.relationType === 'moves_along_path') {
2882
3143
  return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
2883
3144
  }
@@ -3554,6 +3815,15 @@ if (typeof _gen === 'function') {
3554
3815
  for (const relation of relations) {
3555
3816
  const { source, target, type, params = {} } = relation;
3556
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
+ }
3557
3827
  codeParts.push(`
3558
3828
  // Add relation: ${source} ${type} ${target}
3559
3829
  (function() {
@@ -3565,7 +3835,7 @@ if (typeof _gen === 'function') {
3565
3835
  if (app.addRelation(sourceId, targetId, '${type}', params)) {
3566
3836
  results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3567
3837
  } else {
3568
- const __why = ${relationRefusalReasonJs(JSON.stringify(type))};
3838
+ const __why = ${relationRefusalReasonJs(jsLiteral(type))};
3569
3839
  (results.relationsRefused = results.relationsRefused || []).push({ source: '${source}', target: '${target}', type: '${type}', reason: __why || 'the studio refused it' });
3570
3840
  results.success = false;
3571
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.';
@@ -3983,7 +4253,7 @@ throw new Error('Unknown diagram mode action: ${action}');
3983
4253
  if (app.patternGroup) app.patternGroup.removeChildren();
3984
4254
  if (app.itemRegistry) app.itemRegistry.clear();
3985
4255
  if (app.relationRegistry) app.relationRegistry.clear();
3986
- }
4256
+ }${ID_COUNTER_RESET}
3987
4257
  `;
3988
4258
  }
3989
4259
  if (canvasPreset) {
@@ -4226,7 +4496,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4226
4496
  if (app.patternGroup) app.patternGroup.removeChildren();
4227
4497
  if (app.itemRegistry) app.itemRegistry.clear();
4228
4498
  if (app.relationRegistry) app.relationRegistry.clear();
4229
- }
4499
+ }${ID_COUNTER_RESET}
4230
4500
  `;
4231
4501
  }
4232
4502
  else {
@@ -4234,7 +4504,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4234
4504
  // Clear items but preserve background
4235
4505
  if (app.textItemGroup) app.textItemGroup.removeChildren();
4236
4506
  if (app.itemRegistry) app.itemRegistry.clear();
4237
- if (app.relationRegistry) app.relationRegistry.clear();
4507
+ if (app.relationRegistry) app.relationRegistry.clear();${ID_COUNTER_RESET}
4238
4508
  `;
4239
4509
  }
4240
4510
  if (canvasPreset) {
@@ -4265,11 +4535,16 @@ throw new Error('Unknown diagram mode action: ${action}');
4265
4535
  */
4266
4536
  generateAgentBatchExecute(input) {
4267
4537
  const validated = AgentBatchExecuteInputSchema.parse(input);
4268
- const { operations, atomic } = validated;
4538
+ const { atomic } = validated;
4539
+ // ring / grid / orbit / stagger / patch become the batch's own ops (batch-macros.ts).
4540
+ const operations = expandBatchMacros(validated.operations);
4269
4541
  const isAtomic = atomic !== false;
4270
4542
  // Fold duplicate keyframe_animate targets so a later op can't silently
4271
4543
  // clobber an earlier track (app.addAnimation replaces, never merges).
4272
4544
  const mergePlan = planKeyframeMerges(operations);
4545
+ // Timeline ops compile here, on the server: the handler refuses a batch whose timeline does not compile before
4546
+ // anything runs; a direct caller gets the op's own failure instead.
4547
+ const timelines = compileBatchTimelines(operations);
4273
4548
  let code = `
4274
4549
  // Batch execute ${operations.length} operations
4275
4550
  (async function() {
@@ -4284,7 +4559,8 @@ throw new Error('Unknown diagram mode action: ${action}');
4284
4559
  || (typeof v.reason === 'string' && v.reason)
4285
4560
  || 'the operation reported failure without naming a reason';
4286
4561
  };
4287
- // $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
4288
4564
  // previous batch's ids are kept only to name the right id when a $N reaches
4289
4565
  // past what this batch has made: picking it silently shifted every $N in a
4290
4566
  // batch that also creates (B115 follow-up).
@@ -4293,16 +4569,39 @@ throw new Error('Unknown diagram mode action: ${action}');
4293
4569
  const __ref = function (n) {
4294
4570
  if (itemIds[n] !== undefined) return itemIds[n];
4295
4571
  const prev = __prevBatchIds[n];
4296
- throw new Error('$' + n + ' is the item number ' + n + ' THIS batch created, and it has created ' + itemIds.length + ' so far.'
4297
- + (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.'));
4298
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; };
4299
4577
  const results = [];
4300
4578
  let success = true;
4579
+ // A failure's words, never "undefined" for a thrown non-Error.
4580
+ const _why = (e, fb) => (e && typeof e.message === 'string' && e.message.trim()) ? e.message
4581
+ : (typeof e === 'string' && e.trim()) ? e : fb;
4582
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
4583
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
4584
+ // result. A studio without lintScene says nothing.
4585
+ const __ppLintNow = function () {
4586
+ try {
4587
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
4588
+ const r = app.lintScene({ warningsOnly: true });
4589
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
4590
+ } catch (e) {
4591
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
4592
+ }
4593
+ };
4594
+ // Every failed op as { step, itemId, reason }, beside the per-op results.
4595
+ const _failures = () => results.filter((r) => !r.success).map((r) => ({ step: r.step, itemId: r.itemId, reason: r.reason }));
4301
4596
 
4302
4597
  try {
4303
4598
  `;
4304
4599
  operations.forEach((op, index) => {
4305
- const opCode = this.generateBatchOperationCode(op, index, mergePlan);
4600
+ const opCode = op.type === 'timeline'
4601
+ ? (timelines.plans.has(index)
4602
+ ? emitTimelineOpBody(timelines.plans.get(index), (t) => (t.startsWith('$') ? `__ref(${t.substring(1)})` : JSON.stringify(t)))
4603
+ : `return { success: false, error: ${JSON.stringify(timelines.errors.filter((e) => e.path.startsWith(`operations[${index}]`)).map((e) => `${e.path}: ${e.message}`).join('; ') || 'the timeline did not compile')} };`)
4604
+ : this.generateBatchOperationCode(op, index, mergePlan);
4306
4605
  // $N variable references are documented as "items CREATED in earlier
4307
4606
  // operations" — only `create` results may extend the itemIds array.
4308
4607
  // animate/keyframe_animate/modify/delete also return { itemId } (the
@@ -4310,9 +4609,15 @@ throw new Error('Unknown diagram mode action: ${action}');
4310
4609
  // $N reference: a batch of [create ×5, keyframe_animate ×5, create ×5,
4311
4610
  // keyframe_animate ×5] silently re-animated the FIRST five items and
4312
4611
  // left the second five without keyframes (mis-oriented/mis-animated
4313
- // vehicles, 2026-07-30). `group` already follows this rule by
4314
- // returning groupId instead of itemId.
4315
- 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';
4616
+ // The item this op acts on, for a failure's itemId: its own id field, else what its result names.
4617
+ const o = op;
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);
4620
+ const opStep = typeof o._step === 'string' ? `op ${index}: ${o._step}` : `op ${index} (${op.type})`;
4316
4621
  code += `
4317
4622
  // Operation ${index}: ${op.type}
4318
4623
  try {
@@ -4329,7 +4634,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4329
4634
  if (failed${index}) {
4330
4635
  // ATOMIC: throw WITHOUT recording, and let the catch below record it
4331
4636
  // once. Doing both put two entries in for one failed op.
4332
- ${isAtomic ? `throw new Error(failed${index});` : `results.push({ index: ${index}, success: false, 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} });
4333
4638
  success = false;`}
4334
4639
  } else {
4335
4640
  results.push({ index: ${index}, success: true, result: result${index} });
@@ -4338,7 +4643,8 @@ throw new Error('Unknown diagram mode action: ${action}');
4338
4643
  }` : ''}
4339
4644
  }
4340
4645
  } catch (opError) {
4341
- results.push({ index: ${index}, success: false, error: opError.message });
4646
+ const __r${index} = _why(opError, 'operation ${index} (${op.type}) threw with no message');
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} });
4342
4648
  ${isAtomic ? 'throw opError;' : 'success = false;'}
4343
4649
  }
4344
4650
  `;
@@ -4358,15 +4664,19 @@ throw new Error('Unknown diagram mode action: ${action}');
4358
4664
  }
4359
4665
  return { success, itemIds, results, operationCount: ${operations.length},
4360
4666
  failedCount: _failed.length,
4361
- ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
4362
- canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4667
+ ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; '), failures: _failures() } : {}),
4668
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4363
4669
  } catch (e) {
4364
4670
  // A batch that created nothing keeps the previous batch's ids on record.
4365
4671
  if (typeof window !== 'undefined' && itemIds.length) {
4366
4672
  window.__pinepaper_batch_item_ids = itemIds;
4367
4673
  }
4368
4674
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4369
- return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4675
+ const __fails = _failures();
4676
+ const __reason = _why(e, 'the batch failed with no message');
4677
+ return { success: false, error: __reason, itemIds, results, operationCount: ${operations.length},
4678
+ failures: __fails.length ? __fails : [{ step: 'batch', itemId: null, reason: __reason }],
4679
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4370
4680
  }
4371
4681
  })();
4372
4682
  `;
@@ -4403,7 +4713,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4403
4713
  'backgroundColor', 'generatorName', 'generatorParams', 'generatorRegion',
4404
4714
  'width', 'height', 'preset', 'keyframes', 'duration', 'loop',
4405
4715
  'maskPreset', 'maskType', 'maskOptions', 'effectType', 'effectParams',
4406
- 'action', 'time', 'append'
4716
+ 'action', 'time', 'append', '_step'
4407
4717
  ]);
4408
4718
  for (const [k, v] of Object.entries(anyOp)) {
4409
4719
  if (!batchKeys.has(k) && v !== undefined && opProps[k] === undefined) {
@@ -4448,9 +4758,7 @@ return __created;
4448
4758
  return createCode;
4449
4759
  }
4450
4760
  case 'modify': {
4451
- const itemRef = op.itemId?.startsWith('$')
4452
- ? `__ref(${op.itemId.substring(1)})`
4453
- : `'${op.itemId}'`;
4761
+ const itemRef = batchRef(op.itemId);
4454
4762
  // modify_item's own emitter, as create is create_item's (see above):
4455
4763
  // the batch's modifyItem call missed text styles, font loading, audio
4456
4764
  // level, no-fill, lifetimes, smoothing and the unread-property report.
@@ -4458,36 +4766,43 @@ return __created;
4458
4766
  // at run time, so it is generated with a placeholder and the runtime
4459
4767
  // variable put in its place.
4460
4768
  const single = generateModifyItemCode('__MID__', (op.properties || {})).trim()
4461
- .replace(/'__MID__'/g, '__mid')
4769
+ .replace(/"__MID__"/g, '__mid')
4462
4770
  .replace(/__MID__/g, "' + __mid + '")
4463
4771
  .replace(/;\s*$/, '');
4464
4772
  return `
4465
4773
  const __mid = ${itemRef};
4774
+ if (app.itemRegistry && app.itemRegistry.getItem && !app.itemRegistry.getItem(__mid) && typeof app.getItemsByName === 'function') {
4775
+ // A NAME HELD BY SEVERAL ITEMS is refused, not resolved: getItemById answers
4776
+ // the first holder, so a patch by name would change whichever came first.
4777
+ const __held = app.getItemsByName(__mid) || [];
4778
+ if (__held.length > 1) {
4779
+ return { success: false, itemId: __mid, error: __held.length + ' items are named ' + JSON.stringify(__mid) + ' (' + __held.map(function (h) { return (h && h.data && h.data.id) || '?'; }).join(', ') + '); pass one of their ids.' };
4780
+ }
4781
+ }
4466
4782
  const __r = await ${single};
4467
4783
  if (__r && __r.success !== false) __r.modified = true;
4468
4784
  return __r;
4469
4785
  `;
4470
4786
  }
4471
4787
  case 'animate':
4472
- const animItemRef = op.itemId?.startsWith('$')
4473
- ? `__ref(${op.itemId.substring(1)})`
4474
- : `'${op.itemId}'`;
4788
+ const animItemRef = batchRef(op.itemId);
4475
4789
  const animOpts = JSON.stringify(op.animationOptions || {});
4476
4790
  return `
4477
4791
  const targetId = ${animItemRef};
4478
4792
  const item = app.getItemById(targetId);
4479
4793
  if (!item) throw new Error('Item not found: ' + targetId);
4480
- app.animate(item, { animationType: '${op.animationType}', ...${animOpts} });
4481
- 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))} };
4482
4796
  `;
4483
4797
  case 'relation':
4484
- const srcRef = op.sourceId?.startsWith('$')
4485
- ? `__ref(${op.sourceId.substring(1)})`
4486
- : `'${op.sourceId}'`;
4487
- const tgtRef = op.targetId?.startsWith('$')
4488
- ? `__ref(${op.targetId.substring(1)})`
4489
- : `'${op.targetId}'`;
4798
+ const srcRef = batchRef(op.sourceId);
4799
+ const tgtRef = batchRef(op.targetId);
4490
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
+ `;
4491
4806
  return `
4492
4807
  const sourceId = ${srcRef};
4493
4808
  const targetId = ${tgtRef};
@@ -4495,22 +4810,20 @@ const source = app.getItemById(sourceId);
4495
4810
  const target = app.getItemById(targetId);
4496
4811
  if (!source) throw new Error('Source not found: ' + sourceId);
4497
4812
  if (!target) throw new Error('Target not found: ' + targetId);
4498
- const relationId = app.addRelation(source, target, '${op.relationType}', ${relOpts});
4813
+ const relationId = app.addRelation(source, target, ${JSON.stringify(String(op.relationType))}, ${relOpts});
4499
4814
  // NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false
4500
4815
  // when it refuses — spring_follow "not loaded yet" — and this op returned
4501
4816
  // { relationId: false } with nothing marking it failed, so the batch said
4502
4817
  // success over a relation that does not exist.
4503
4818
  if (!relationId) {
4504
- const __why = ${relationRefusalReasonJs(JSON.stringify(op.relationType))};
4819
+ const __why = ${relationRefusalReasonJs(jsLiteral(op.relationType))};
4505
4820
  return { success: false, relationId: false, sourceId, targetId, relationType: ${JSON.stringify(op.relationType)},
4506
4821
  error: 'the studio refused the ' + ${JSON.stringify(op.relationType)} + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };
4507
4822
  }
4508
- return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4823
+ return { relationId, sourceId, targetId, relationType: ${JSON.stringify(String(op.relationType))} };
4509
4824
  `;
4510
4825
  case 'delete':
4511
- const delRef = op.itemId?.startsWith('$')
4512
- ? `__ref(${op.itemId.substring(1)})`
4513
- : `'${op.itemId}'`;
4826
+ const delRef = batchRef(op.itemId);
4514
4827
  return `
4515
4828
  const targetId = ${delRef};
4516
4829
  const item = app.getItemById(targetId);
@@ -4521,10 +4834,10 @@ return { itemId: targetId, deleted: true };
4521
4834
  `;
4522
4835
  case 'group': {
4523
4836
  // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
4524
- const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `__ref(${ref.substring(1)})` : `'${ref}'`);
4837
+ const refs = (op.itemIds || []).map((ref) => batchRef(ref));
4525
4838
  const idsArrayExpr = `[${refs.join(', ')}]`;
4526
4839
  const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
4527
- // 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.
4528
4841
  return `
4529
4842
  return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4530
4843
  })();
@@ -4533,8 +4846,8 @@ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4533
4846
  case 'set_background':
4534
4847
  const bgColor = op.backgroundColor || op.color || op.properties?.backgroundColor || op.properties?.color || '#000000';
4535
4848
  return `
4536
- app.setBackgroundColor('${bgColor}');
4537
- return { success: true, backgroundColor: '${bgColor}' };
4849
+ app.setBackgroundColor(${JSON.stringify(String(bgColor))});
4850
+ return { success: true, backgroundColor: ${JSON.stringify(String(bgColor))} };
4538
4851
  `;
4539
4852
  case 'execute_generator':
4540
4853
  const genName = op.generatorName || 'drawSunburst';
@@ -4565,14 +4878,12 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
4565
4878
  return `
4566
4879
  // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
4567
4880
  // is replaced wholesale, so emitting both here would silently drop one.
4568
- return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)})` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
4881
+ return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget} };
4569
4882
  `;
4570
4883
  }
4571
4884
  const mergedPayload = mergePlan?.merged.get(index);
4572
4885
  const rawItemId = String(op.itemId ?? op.id ?? op.properties?.itemId ?? op.properties?.id);
4573
- const kfItemRef = rawItemId?.startsWith('$')
4574
- ? `__ref(${rawItemId.substring(1)})`
4575
- : `'${rawItemId}'`;
4886
+ const kfItemRef = batchRef(rawItemId);
4576
4887
  const kfSource = mergedPayload
4577
4888
  ? mergedPayload.keyframes
4578
4889
  : (op.keyframes || (op.properties && op.properties.keyframes) || (op.params && op.params.keyframes) || []);
@@ -4614,9 +4925,7 @@ return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoo
4614
4925
  `;
4615
4926
  }
4616
4927
  case 'apply_mask': {
4617
- const maskItemRef = op.itemId?.startsWith('$')
4618
- ? `__ref(${op.itemId.substring(1)})`
4619
- : `'${op.itemId}'`;
4928
+ const maskItemRef = batchRef(op.itemId);
4620
4929
  const maskOpts = JSON.stringify(op.maskOptions || {});
4621
4930
  const preset = op.maskPreset || op.maskType || 'wipeLeft';
4622
4931
  return `
@@ -4633,9 +4942,7 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
4633
4942
  `;
4634
4943
  }
4635
4944
  case 'apply_effect': {
4636
- const effItemRef = op.itemId?.startsWith('$')
4637
- ? `__ref(${op.itemId.substring(1)})`
4638
- : `'${op.itemId}'`;
4945
+ const effItemRef = batchRef(op.itemId);
4639
4946
  const effParams = JSON.stringify(op.effectParams || {});
4640
4947
  return `
4641
4948
  const targetId = ${effItemRef};
@@ -5120,6 +5427,19 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5120
5427
  // cannot be kept accurate — it breaks on any edit above it — so this repo
5121
5428
  // does not use them. Camera animation still
5122
5429
  // drives motion during export — framing only fixes the output frame.
5430
+ // THE SCENE LINT, BEFORE THE EXPORT (FxTool lintScene().warnings): what the file will show wrong without
5431
+ // anything failing — a centre-anchored text that runs out of the frame exports as "IGNAL" for "SIGNAL". Read from the
5432
+ // scene as it is, never a refusal; a studio without the rule says nothing, a lint that throws says so.
5433
+ const __lint = (function () {
5434
+ if (typeof app.lintScene !== 'function') return null;
5435
+ try {
5436
+ const r = app.lintScene({ warningsOnly: true });
5437
+ return r && Array.isArray(r.warnings) ? r.warnings : null;
5438
+ } catch (e) {
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') }];
5440
+ }
5441
+ })();
5442
+
5123
5443
  let cameraDims = null;
5124
5444
  if (framing === 'camera') {
5125
5445
  if (!['gif', 'apng', 'mp4', 'webm'].includes(format)) {
@@ -5200,14 +5520,36 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5200
5520
  });
5201
5521
 
5202
5522
  let result = { success: false, platform, format, quality, framing };
5203
- // 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
5204
5546
  // MCP's frame callback are applied by the frame loop, and a seek does not run
5205
5547
  // it — so a png at time 0.8 s drew nothing staged at 0.8 s, and an item past
5206
5548
  // its ttl still showed (retest). The visibility pass runs here for stills.
5207
5549
  if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1) {
5208
5550
  const __L = globalThis.__ppMcp;
5209
5551
  if (__L && __L.lifetimeApp === app && typeof __L.applyLifetimes === 'function') {
5210
- 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 */ }
5211
5553
  }
5212
5554
  }
5213
5555
  // The engine's report on the audio track of THIS export (FxTool b41860aa:
@@ -5216,18 +5558,19 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5216
5558
  const __vx = app.exportEngine && app.exportEngine.videoExporter;
5217
5559
  if (__vx && 'lastAudioReport' in __vx) __vx.lastAudioReport = null;
5218
5560
  if (__vx && 'lastVideoReport' in __vx) __vx.lastVideoReport = null;
5219
- ${stillTime !== undefined ? `
5220
5561
  // A STILL AT A CHOSEN MOMENT (round 7 X, 1.57). Without this a png is
5221
5562
  // whatever frame the playhead is on — two identical builds gave PNGs that
5222
5563
  // differed in the animated streak while their MP4s were byte-identical.
5223
5564
  // Seek, render, and put the playhead back afterwards.
5224
5565
  const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
5225
- if (typeof app.setPlaybackTime !== 'function') {
5226
- 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();
5227
5572
  }
5228
- app.setPlaybackTime(${stillTime});
5229
- if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
5230
- ` : ''}
5573
+
5231
5574
  try {
5232
5575
  // jpg / webp render as png, then re-encode below.
5233
5576
  switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {
@@ -5262,7 +5605,7 @@ ${stillTime !== undefined ? `
5262
5605
  if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
5263
5606
  // A video in the region is waited for at this time (round 12, 12.5):
5264
5607
  // the render drew before the clip's seek landed.
5265
- 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));
5266
5609
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
5267
5610
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
5268
5611
  // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
@@ -5319,7 +5662,9 @@ ${stillTime !== undefined ? `
5319
5662
  || app.canvasSize
5320
5663
  || { width: dimensions.width, height: dimensions.height };
5321
5664
  const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
5322
- 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 } : {}) });
5323
5668
  const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
5324
5669
  result = {
5325
5670
  success: true,
@@ -5668,7 +6013,7 @@ ${stillTime !== undefined ? `
5668
6013
  if (typeof fr.embedded === 'number') __tl.fonts.embedded = fr.embedded; // cumulative for the document
5669
6014
  if (typeof fr.hiddenTextItems === 'number') __tl.fonts.hiddenTextItems = (__tl.fonts.hiddenTextItems || 0) + fr.hiddenTextItems;
5670
6015
  }
5671
- } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: e && e.message }; }
6016
+ } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: (e && typeof e.message === 'string' && e.message) || 'embedding the text fonts failed with no reason given' }; }
5672
6017
  }
5673
6018
  if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
5674
6019
  if (__layer) {
@@ -5676,7 +6021,7 @@ ${stillTime !== undefined ? `
5676
6021
  const tr = await app.exportEngine.addTextLayer(doc, { x: 0, y: 0, width: wMM, height: hMM });
5677
6022
  if (tr && tr.ok !== false) { __tl.written += tr.linesWritten || 0; __tl.skipped += tr.linesSkipped || 0; }
5678
6023
  else __tl.reasons.push(id + ': ' + ((tr && tr.reason) || 'refused'));
5679
- } catch (e) { __tl.reasons.push(id + ': ' + e.message); }
6024
+ } catch (e) { __tl.reasons.push(id + ': ' + ((e && typeof e.message === 'string' && e.message) || 'the text layer failed with no reason given')); }
5680
6025
  }
5681
6026
  pagesOut.push({ sceneId: id, widthMM: Math.round(wMM * 10) / 10, heightMM: Math.round(hMM * 10) / 10 });
5682
6027
  }
@@ -5739,7 +6084,7 @@ ${stillTime !== undefined ? `
5739
6084
  result = { success: false, error: 'Unknown format: ' + format };
5740
6085
  }
5741
6086
  } catch (e) {
5742
- result = { success: false, error: e.message, platform, format };
6087
+ result = { success: false, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('the ' + format + ' export threw with no message'), platform, format };
5743
6088
  }
5744
6089
 
5745
6090
  // One site, after every branch has set its result: a successful export says
@@ -5777,11 +6122,19 @@ ${stillTime !== undefined ? `
5777
6122
  result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });
5778
6123
  }
5779
6124
  } catch (e) {
5780
- result = { success: false, platform, format, error: (e && e.message) || String(e) };
6125
+ result = { success: false, platform, format, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('re-encoding the still as ' + format + ' failed with no message') };
5781
6126
  }
5782
6127
  }
5783
6128
 
5784
6129
  if (result && result.success) Object.assign(result, fidelity(format));
6130
+ if (result && result.success && __lint && __lint.length && !result.lintWarnings) {
6131
+ result.lintWarnings = __lint;
6132
+ result.fidelity = result.fidelity || { warnings: [] };
6133
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__lint.map(function (w) {
6134
+ return { code: String(w.code || 'lint'), message: String(w.message || w.code || 'scene lint warning'), ...(w.itemId ? { items: [w.itemId] } : {}) };
6135
+ }));
6136
+ if (result.fidelity.note) delete result.fidelity.note;
6137
+ }
5785
6138
  if (result && result.success && __fit) result.platformFit = __fit;
5786
6139
  ${usesCanvasSize ? '' : ` // A PRESET SIZE IS NOT THE CANVAS SIZE, AND THAT IS SAID (21 PLATFORMOVERRIDE).
5787
6140
  // A named platform renders the FILE at the preset's size; the canvas is not
@@ -6035,8 +6388,10 @@ ${(() => {
6035
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).`,
6036
6389
  })};\n`;
6037
6390
  })()}
6038
- ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
6039
- 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
+ }
6040
6395
  // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the
6041
6396
  // camera's frame map, so a HUD item is placed as if the camera had not moved.
6042
6397
  if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(${JSON.stringify(format)}) !== -1) {
@@ -6764,7 +7119,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6764
7119
  // unchanged. The map itself cannot be moved without breaking geoToCanvas
6765
7120
  // (it projects against the untransformed map, so markers would land in the
6766
7121
  // wrong place). Until the engine has a map view transform, say so.
6767
- return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('pan'))} }; })();`;
7122
+ return `(function() { return { success: false, error: ${jsLiteral(MAP_VIEW_REFUSAL('pan'))} }; })();`;
6768
7123
  }
6769
7124
  /**
6770
7125
  * Generate code to zoom the map
@@ -6777,7 +7132,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6777
7132
  if (validated.duration !== undefined)
6778
7133
  options.duration = validated.duration;
6779
7134
  // Refused for the same reason as pan (see generatePanMap).
6780
- return `(function() { return { success: false, error: ${JSON.stringify(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
7135
+ return `(function() { return { success: false, error: ${jsLiteral(MAP_VIEW_REFUSAL('zoom'))} }; })();`;
6781
7136
  }
6782
7137
  /**
6783
7138
  * Generate code to export map configuration
@@ -7713,14 +8068,41 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7713
8068
  // An async IIFE is still a `(`-led expression statement, so the governor
7714
8069
  // captures its value, and executeCode's eval fallback already resolves a
7715
8070
  // returned promise. Both paths await it; neither needed changing.
8071
+ //
8072
+ // The code runs inside its own async arrow so its `return` is still its value; the value comes back under
8073
+ // __ppValue with the scene lint beside it, and a throw comes back as { step, itemId, reason }, never a bare
8074
+ // message (and never "undefined" for a thrown non-Error).
7716
8075
  return `
7717
8076
  // ${description}
7718
8077
  (async function() {
8078
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
8079
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
8080
+ // result. A studio without lintScene says nothing.
8081
+ const __ppLintNow = function () {
8082
+ try {
8083
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
8084
+ const r = app.lintScene({ warningsOnly: true });
8085
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
8086
+ } catch (e) {
8087
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
8088
+ }
8089
+ };
8090
+ let __ppValue;
7719
8091
  try {
8092
+ __ppValue = await (async () => {
7720
8093
  ${validated.code}
8094
+ })();
7721
8095
  } catch (error) {
7722
- return { success: false, error: error.message };
7723
- }
8096
+ const __ppReason = (error && typeof error.message === 'string' && error.message.trim()) ? error.message
8097
+ : (typeof error === 'string' && error.trim()) ? error : 'the custom code threw with no message';
8098
+ return { success: false, step: 'execute_custom_code', itemId: (error && typeof error.itemId === 'string') ? error.itemId : null,
8099
+ reason: __ppReason, error: __ppReason, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
8100
+ }
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, {}.
8104
+ const __ppL = __ppLintNow();
8105
+ return __ppL.length ? { __ppWrapped: true, __ppValue: __ppValue, __ppLint: __ppL } : __ppValue;
7724
8106
  })();
7725
8107
  `.trim();
7726
8108
  }
@@ -8051,7 +8433,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
8051
8433
  `.trim();
8052
8434
  }
8053
8435
  generateImportImage(input) {
8054
- 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;
8055
8437
  // IMPORTSHADOW: the shadow keys, applied through the engine's own modify
8056
8438
  // door once the image is placed (and masked) — the route modify_item takes.
8057
8439
  const shadow = {};
@@ -8169,6 +8551,11 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8169
8551
  ? { applied: true, shadowColor: placed.shadowColor ? String(placed.shadowColor.toCSS ? placed.shadowColor.toCSS(true) : placed.shadowColor) : null, shadowBlur: placed.shadowBlur }
8170
8552
  : { applied: false, warning: 'the image was imported but its shadow was not applied — set it with pinepaper_modify_item on ' + itemId + '.' };
8171
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)};
8172
8559
  ` : ''} const b = placed.bounds;
8173
8560
  if (!b || !(b.width > 0) || !(b.height > 0)) {
8174
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.' };
@@ -8176,7 +8563,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8176
8563
  return {
8177
8564
  success: true,
8178
8565
  itemId: itemId,
8179
- message: 'Image imported and placed on canvas.',
8566
+ message: 'Image imported and placed on canvas.',${name ? `\n name: ${JSON.stringify(name)},` : ''}
8180
8567
  bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
8181
8568
  // Stored smaller than its source (the studio's cap): said, with the
8182
8569
  // way to keep every pixel. The studio reports native size where it can.
@@ -8528,6 +8915,53 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
8528
8915
  };
8529
8916
  })();
8530
8917
  `.trim();
8918
+ }
8919
+ /**
8920
+ * pinepaper_pattern (AR-2 P2): one stored declaration moves many items (FxTool app.addPattern / modifyPattern /
8921
+ * removePattern / listPatterns / getPattern). No keyframes are written; the engine evaluates the node per frame.
8922
+ */
8923
+ generatePattern(input) {
8924
+ const v = PatternInputSchema.parse(input);
8925
+ const S = (x) => JSON.stringify(x);
8926
+ const need = (fn) => `if (typeof app.${fn} !== 'function') return { success: false, error: 'app.${fn} unavailable — this studio predates patterns (FxTool AR-2 P1); update it' };`;
8927
+ const idNeeded = v.action !== 'list' && !v.id ? `return { success: false, error: '${v.action} needs the pattern id' };` : '';
8928
+ let body;
8929
+ switch (v.action) {
8930
+ case 'create': {
8931
+ const spec = { id: v.id, kind: v.kind, members: v.members, params: v.params || {}, ...(v.window ? { window: v.window } : {}) };
8932
+ body = `${need('addPattern')}
8933
+ if (!${S(v.kind ?? null)}) return { success: false, error: ${S('create needs kind: ' + PATTERN_KINDS.join(', '))} };
8934
+ if (!${S(v.members ?? null)}) return { success: false, error: 'create needs members: { names: [...] }, { ids: [...] }, { tag } or an array' };
8935
+ const r = app.addPattern(${S(spec)});
8936
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8937
+ break;
8938
+ }
8939
+ case 'modify': {
8940
+ const ch = { ...(v.params ? { params: v.params } : {}), ...(v.members ? { members: v.members } : {}), ...(v.window !== undefined ? { window: v.window } : {}) };
8941
+ body = `${need('modifyPattern')}
8942
+ const r = app.modifyPattern(${S(v.id)}, ${S(ch)});
8943
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8944
+ break;
8945
+ }
8946
+ case 'remove':
8947
+ body = `${need('removePattern')}
8948
+ const had = app.removePattern(${S(v.id)});
8949
+ return had ? { success: true, removed: ${S(v.id)} } : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8950
+ break;
8951
+ case 'get':
8952
+ body = `${need('getPattern')}
8953
+ const p = app.getPattern(${S(v.id)});
8954
+ return p ? Object.assign({ success: true }, p) : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8955
+ break;
8956
+ default:
8957
+ body = `${need('listPatterns')}
8958
+ const list = app.listPatterns();
8959
+ return { success: true, count: list.length, patterns: list };`;
8960
+ }
8961
+ return `(function() {
8962
+ ${idNeeded}
8963
+ ${body}
8964
+ })();`;
8531
8965
  }
8532
8966
  generateStagger(input) {
8533
8967
  const validated = StaggerInputSchema.parse(input);
@@ -9162,6 +9596,8 @@ ${resolveSource}
9162
9596
  })();`.trim();
9163
9597
  }
9164
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 || '');
9165
9601
  switch (input.action) {
9166
9602
  case 'fit': {
9167
9603
  const mode = input.mode || 'contain';
@@ -9173,31 +9609,37 @@ ${resolveSource}
9173
9609
  // id space from canvas item ids. PinePaperAgent.fitToFrame resolves either.
9174
9610
  const A = (typeof window !== 'undefined') && window.PinePaperAgent;
9175
9611
  if (A && typeof A.fitToFrame === 'function') {
9176
- const ok = A.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
9612
+ const ok = A.fitToFrame(${ID}, ${JSON.stringify(mode)});
9177
9613
  return ok
9178
- ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
9179
- : { 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} };
9180
9616
  }
9181
9617
  if (typeof app.fitToFrame !== 'function') {
9182
9618
  return { success: false, error: 'fitToFrame unavailable — update FxTool' };
9183
9619
  }
9184
- const ok = app.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
9620
+ const ok = app.fitToFrame(${ID}, ${JSON.stringify(mode)});
9185
9621
  return ok
9186
- ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
9187
- : { 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} };
9188
9624
  })();`.trim();
9189
9625
  }
9190
9626
  case 'nudge': {
9191
- const dx = input.dx ?? 0;
9192
- 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.
9193
9633
  return `
9194
9634
  // Nudge item position
9195
9635
  (function() {
9196
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9197
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
9198
- entry.item.position.x += ${dx};
9199
- entry.item.position.y += ${dy};
9200
- 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 } };
9201
9643
  })();`.trim();
9202
9644
  }
9203
9645
  case 'flip': {
@@ -9205,10 +9647,10 @@ ${resolveSource}
9205
9647
  return `
9206
9648
  // Flip item ${dir}ly
9207
9649
  (function() {
9208
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9209
- 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} };
9210
9652
  ${dir === 'horizontal' ? 'entry.item.scale(-1, 1);' : 'entry.item.scale(1, -1);'}
9211
- return { success: true, action: 'flip', itemId: '${input.itemId}', direction: '${dir}' };
9653
+ return { success: true, action: 'flip', itemId: ${ID}, direction: ${JSON.stringify(dir)} };
9212
9654
  })();`.trim();
9213
9655
  }
9214
9656
  case 'reorder': {
@@ -9226,20 +9668,20 @@ ${resolveSource}
9226
9668
  return `
9227
9669
  // Reorder item: ${order}
9228
9670
  (function() {
9229
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9230
- 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} };
9231
9673
  const sibling = ${siblingExpr};
9232
9674
  if (sibling) entry.item.${method}(sibling);
9233
- return { success: true, action: 'reorder', itemId: '${input.itemId}', order: '${order}' };
9675
+ return { success: true, action: 'reorder', itemId: ${ID}, order: ${JSON.stringify(order)} };
9234
9676
  })();`.trim();
9235
9677
  }
9236
9678
  return `
9237
9679
  // Reorder item: ${order}
9238
9680
  (function() {
9239
- const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
9240
- 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} };
9241
9683
  entry.item.${method}();
9242
- return { success: true, action: 'reorder', itemId: '${input.itemId}', order: '${order}' };
9684
+ return { success: true, action: 'reorder', itemId: ${ID}, order: ${JSON.stringify(order)} };
9243
9685
  })();`.trim();
9244
9686
  }
9245
9687
  default:
@@ -10026,7 +10468,7 @@ ${resolve}
10026
10468
  // HAND-DRAWN SKETCHY ANIMATION & JITTER
10027
10469
  // ===========================================================================
10028
10470
  generateHandDrawn(input) {
10029
- const S = (v) => JSON.stringify(v);
10471
+ const S = jsLiteral;
10030
10472
  // style carries no schema default: with one, `mode` (its alias) was never read.
10031
10473
  const requested = input.style ?? input.mode ?? 'sketchy';
10032
10474
  const style = requested === 'ink_reveal' ? 'reveal' : requested;
@@ -10368,6 +10810,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10368
10810
  (async function() {
10369
10811
  if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
10370
10812
  const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
10813
+ if (!result || typeof result.json !== 'string' || result.json.length === 0) {
10814
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidget returned no scene JSON' + (result && result.error ? ' (' + result.error + ')' : '') };
10815
+ }
10371
10816
  // THE EMBED BOX WAS ALWAYS 16:9, AND IT IS NOT SELF-CONTAINED (round 9 GG,
10372
10817
  // 8.32). The engine writes aspect-ratio: 16/9 and max-width: 800px whatever
10373
10818
  // the scene, so a 300x250 ad sat letterboxed in a wide box. The box is sized
@@ -10403,13 +10848,16 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10403
10848
  (async function() {
10404
10849
  if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
10405
10850
  const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
10851
+ if (!result || typeof result.html !== 'string' || result.html.length === 0) {
10852
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidgetHTML returned no page' + (result && result.error ? ' (' + result.error + ')' : '') };
10853
+ }
10406
10854
  ${input.lang || input.dir || input.alt ? `// lang / dir / alt on the engine's page (it writes <html lang="en"> and no
10407
10855
  // text alternative). Applied as string edits on the one <html> tag and the
10408
10856
  // first <canvas>; the result says which landed.
10409
10857
  const __esc = function(v) { return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); };
10410
10858
  let __html = String(result.html || '');
10411
10859
  const __applied = [];
10412
- ${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}"` : '')} + '>'; });` : ''}
10413
10861
  ${input.alt ? `// The engine's widget draws its canvas from script, so there is no <canvas>
10414
10862
  // tag to label (prod retest): its accessible name is div#w's aria-label,
10415
10863
  // mirrored in the screen-reader text #w-desc. Both carry the alt; a literal
@@ -10438,7 +10886,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10438
10886
  return `(function() { return { error: 'data is required for create' }; })();`;
10439
10887
  const fields = resolveChartFields(input.chartType, input.data, input.options || {});
10440
10888
  if ('error' in fields)
10441
- return `(function() { return { success: false, error: ${JSON.stringify(fields.error)} }; })();`;
10889
+ return `(function() { return { success: false, error: ${jsLiteral(fields.error)} }; })();`;
10442
10890
  const dataStr = JSON.stringify(input.data);
10443
10891
  const optsStr = JSON.stringify(fields.options);
10444
10892
  return `
@@ -10924,8 +11372,8 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10924
11372
  if (app.historyManager) app.historyManager.saveState();
10925
11373
  const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
10926
11374
  // itemId too (round 8 BB, 1.70): every other create answers itemId, and a
10927
- // group is an item every other tool addresses by that id. In a batch this
10928
- // 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).
10929
11377
  return { success: true, groupId: groupId, itemId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
10930
11378
  }
10931
11379
  /**
@@ -11232,6 +11680,14 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11232
11680
  canvas: input.canvas,
11233
11681
  defaultGeometry: input.defaultGeometry,
11234
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
+ }
11235
11691
  const opsJson = JSON.stringify(ops, null, 2);
11236
11692
  const diagJson = JSON.stringify(diagnostics);
11237
11693
  const docJson = JSON.stringify(input.doc);
@@ -11325,7 +11781,10 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11325
11781
  return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
11326
11782
  }
11327
11783
  const result = app.lintScene(${optsJson});
11328
- return { success: true, density: result.density, suggestions: result.suggestions };
11784
+ // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the
11785
+ // frame edge). Passed through when the studio reports them; an older studio has none to pass.
11786
+ return { success: true, density: result.density, suggestions: result.suggestions,
11787
+ ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}) };
11329
11788
  })();`.trim();
11330
11789
  }
11331
11790
  /**
@@ -12251,8 +12710,10 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12251
12710
  }
12252
12711
  }
12253
12712
  /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
12254
- generateEmitter(input) {
12255
- const S = (v) => JSON.stringify(v);
12713
+ generateEmitter(raw) {
12714
+ const S = jsLiteral;
12715
+ const normalized = normalizeEmitterSpec(raw.spec, raw.action !== 'set');
12716
+ const input = { ...raw, spec: normalized.spec };
12256
12717
  // A COUNT OVER THE CAP IS SAID (B144). The cap is 10000 a burst on the
12257
12718
  // current studio and was 2000 before it, clamped in silence (2300 drew
12258
12719
  // ~1940). Which applies is read from the spec the studio STORED — its
@@ -12274,7 +12735,10 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12274
12735
  }
12275
12736
  return out;
12276
12737
  }` : '';
12277
- const capJs = (stored) => checks.length ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(__capWarnings(${stored})))` : '';
12738
+ const fixed = normalized.warnings;
12739
+ const capJs = (stored) => checks.length || fixed.length
12740
+ ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(${S(fixed)}.concat(${checks.length ? `__capWarnings(${stored})` : '[]'})))`
12741
+ : '';
12278
12742
  if (input.action === 'set') {
12279
12743
  return `
12280
12744
  // Emitter: change a spec
@@ -13562,10 +14026,15 @@ ${guard('exportPNGSequence')}
13562
14026
  return Object.assign({ success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size }, __pngSeqDefaults);
13563
14027
  }
13564
14028
  if (Array.isArray(out)) {
13565
- return { success: true, format: 'png-sequence', frames: out.length,
14029
+ // NOT A SAVED FILE, and said so in fields: file null, and why.
14030
+ return { success: true, format: 'png-sequence', frames: out.length, file: null,
14031
+ why: 'the frames stayed in the page: the studio returned them as a list of frames, not a file, so no file was written. Pass download: true to have the browser download them as a zip.',
13566
14032
  note: 'the frames stayed in the page — pass download: true to write them out.' };
13567
14033
  }
13568
- return { success: true, format: 'png-sequence', result: out };
14034
+ // download: true — the studio handed the zip to the BROWSER's download, so no bytes reached this server and no file
14035
+ // was written by it. Said in fields, so it cannot be read as a saved file.
14036
+ return { success: true, format: 'png-sequence', result: out, file: null,
14037
+ why: 'the studio handed the zip to the browser as a download (download: true): it went to the browser\\'s download folder, no bytes reached the MCP server, and the server wrote no file.' };
13569
14038
  })();`.trim();
13570
14039
  }
13571
14040
  }