@pinepaper.studio/mcp-server 1.6.30 → 1.6.32

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 (109) 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 +131 -2
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/SECURITY.md +15 -0
  11. package/dist/agent/types.d.ts +10 -10
  12. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  13. package/dist/browser/puppeteer-controller.js +83 -43
  14. package/dist/browser/puppeteer-controller.js.map +1 -1
  15. package/dist/build-info.json +2 -2
  16. package/dist/cli.js +2 -0
  17. package/dist/cli.js.map +1 -1
  18. package/dist/i18n/i18n-manager.d.ts.map +1 -1
  19. package/dist/i18n/i18n-manager.js +57 -3
  20. package/dist/i18n/i18n-manager.js.map +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp/server.d.ts +12 -0
  25. package/dist/mcp/server.d.ts.map +1 -1
  26. package/dist/mcp/server.js +32 -2
  27. package/dist/mcp/server.js.map +1 -1
  28. package/dist/mcp/stdio.d.ts +4 -0
  29. package/dist/mcp/stdio.d.ts.map +1 -1
  30. package/dist/mcp/stdio.js +22 -0
  31. package/dist/mcp/stdio.js.map +1 -1
  32. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  33. package/dist/ontology/upstream-vocabulary.js +2 -2
  34. package/dist/ontology/vocabulary.d.ts.map +1 -1
  35. package/dist/ontology/vocabulary.js +18 -0
  36. package/dist/ontology/vocabulary.js.map +1 -1
  37. package/dist/tools/batch-op-names.d.ts +33 -0
  38. package/dist/tools/batch-op-names.d.ts.map +1 -0
  39. package/dist/tools/batch-op-names.js +140 -0
  40. package/dist/tools/batch-op-names.js.map +1 -0
  41. package/dist/tools/choreograph.d.ts +8 -0
  42. package/dist/tools/choreograph.d.ts.map +1 -1
  43. package/dist/tools/choreograph.js +9 -7
  44. package/dist/tools/choreograph.js.map +1 -1
  45. package/dist/tools/definitions.d.ts.map +1 -1
  46. package/dist/tools/definitions.js +109 -45
  47. package/dist/tools/definitions.js.map +1 -1
  48. package/dist/tools/effect-class.d.ts.map +1 -1
  49. package/dist/tools/effect-class.js +7 -0
  50. package/dist/tools/effect-class.js.map +1 -1
  51. package/dist/tools/engine-lists.d.ts +7 -4
  52. package/dist/tools/engine-lists.d.ts.map +1 -1
  53. package/dist/tools/engine-lists.js +17 -4
  54. package/dist/tools/engine-lists.js.map +1 -1
  55. package/dist/tools/engine-methods.d.ts.map +1 -1
  56. package/dist/tools/engine-methods.js +3 -0
  57. package/dist/tools/engine-methods.js.map +1 -1
  58. package/dist/tools/engine-surface.d.ts +1 -1
  59. package/dist/tools/engine-surface.d.ts.map +1 -1
  60. package/dist/tools/engine-surface.js +21 -4
  61. package/dist/tools/engine-surface.js.map +1 -1
  62. package/dist/tools/handlers/generate.d.ts +2 -2
  63. package/dist/tools/handlers.d.ts +7 -0
  64. package/dist/tools/handlers.d.ts.map +1 -1
  65. package/dist/tools/handlers.js +63 -4
  66. package/dist/tools/handlers.js.map +1 -1
  67. package/dist/tools/lane.d.ts +1 -1
  68. package/dist/tools/lane.d.ts.map +1 -1
  69. package/dist/tools/lane.js +1 -1
  70. package/dist/tools/lane.js.map +1 -1
  71. package/dist/tools/minimal-descriptions.js +1 -1
  72. package/dist/tools/minimal-descriptions.js.map +1 -1
  73. package/dist/tools/shape-params.d.ts +2 -2
  74. package/dist/tools/shape-params.d.ts.map +1 -1
  75. package/dist/tools/shape-params.js +8 -2
  76. package/dist/tools/shape-params.js.map +1 -1
  77. package/dist/tools/stick-vocabulary.d.ts +4 -4
  78. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  79. package/dist/tools/stick-vocabulary.js +15 -4
  80. package/dist/tools/stick-vocabulary.js.map +1 -1
  81. package/dist/types/code-generator.d.ts +25 -1
  82. package/dist/types/code-generator.d.ts.map +1 -1
  83. package/dist/types/code-generator.js +362 -105
  84. package/dist/types/code-generator.js.map +1 -1
  85. package/dist/types/schemas.d.ts +246 -105
  86. package/dist/types/schemas.d.ts.map +1 -1
  87. package/dist/types/schemas.js +70 -6
  88. package/dist/types/schemas.js.map +1 -1
  89. package/dist/utils/capabilities.d.ts.map +1 -1
  90. package/dist/utils/capabilities.js +3 -1
  91. package/dist/utils/capabilities.js.map +1 -1
  92. package/dist/utils/env.d.ts +19 -6
  93. package/dist/utils/env.d.ts.map +1 -1
  94. package/dist/utils/env.js +34 -6
  95. package/dist/utils/env.js.map +1 -1
  96. package/dist/utils/exit-when-client-gone.d.ts +10 -0
  97. package/dist/utils/exit-when-client-gone.d.ts.map +1 -0
  98. package/dist/utils/exit-when-client-gone.js +21 -0
  99. package/dist/utils/exit-when-client-gone.js.map +1 -0
  100. package/dist/utils/safe-fetch.d.ts +1 -0
  101. package/dist/utils/safe-fetch.d.ts.map +1 -1
  102. package/dist/utils/safe-fetch.js +3 -4
  103. package/dist/utils/safe-fetch.js.map +1 -1
  104. package/dist/vendor/design/design-style.d.ts +2 -2
  105. package/dist/vendor/design/design-style.d.ts.map +1 -1
  106. package/dist/vendor/design/design-style.js +5 -2
  107. package/dist/vendor/design/design-style.js.map +1 -1
  108. package/manifest.json +2 -2
  109. package/package.json +2 -2
@@ -424,23 +424,24 @@ IMPORTANT NOTES:
424
424
  "rking feature does nothing.\n- DO NOT take screenshots after every create operation. Trust the API response - if it returns success, the item was created. Take ONE screenshot at the end to verify the final result.\n\n⚠️ COMPLEX CHARACTERS/ILLUSTRATIONS:\nFor requests like \"draw a witch\", \"draw a cat face\", \"draw a person\", or any detailed characters:\n- These are VERY time-consuming to draw with paths (10-20+ operations per character)\n- Use pinepaper_import_svg instead - import pre-made SVG graphics\n- Or compose from simple shapes (circle for head, triangle for hat, etc.)\n- DO NOT attempt pixel-level character drawing with paths\n\nITEM TYPES:\n\nEVERY type below also takes an anc" +
425
425
  "hor, which says WHICH CORNER your x/y meant.\nPosition is the bounding-box CENTRE by default. Anything that computed a layout\nbox — a design system, or any CSS-shaped mental model — authored the TOP-LEFT\ninstead, and passing those coordinates straight in displaces every item by half\nits own size. anchor: 'top-left' (also top-right, bottom-left, bottom-right,\ncenter; origin is an alias for the parameter) fixes that without you computing\nbounds you cannot know until the item exists. It is applied after size and\nrotation, so it uses the item's real box.\nPIXEL-EXACT SLOTS (a spec'd 615x345 end-screen box, a UI frame): use anchor\n'top-left' with WHOLE-NUMBER x, y, width and height. A ce" +
426
426
  "ntred box with an odd\nwidth or height has its edges on half pixels, which renders 1 px short with a\nsoft anti-aliased edge (measured: 615x345 centred came out 614x344).\n\nEVERY type below also takes a label, which wraps the item in a labelled group —\nyou do not need a second create call and a hand-computed position to caption a\nshape. A string is the short form; the object form places and styles it:\nlabel: { content: 'Start', position: 'top', offset: [0, 10], fontSize: 14,\nfontFamily: 'Inter, sans-serif', color: '#ffffff', backgroundColor: null }.\nposition takes center (default), top, bottom, left, right or a corner. Use\nlabel.position for placement: it works here today, and where" +
427
- " the sibling\nlabelPosition spelling is also accepted, label.position wins.\nLabelling a text item gives you TWO text items in the group — the body and the\nlabel. That is what you want for a caption; it is a surprise if you expected\nthe label to replace the content.\n\n- text: Text content with font styling (properties: content, fontSize, color, fontFamily, justification, fontWeight, fontStyle, leading, lineHeight)\n fontWeight: 400 / 700 / 'bold'. fontStyle: 'italic'. leading: line spacing in px; lineHeight: a multiple of fontSize (<= 4) or px. Multi-line content splits on \n.\n fit: {maxWidth, maxHeight?, minFontSize? (8), maxFontSize? (current), wrap? (true), hold? ('top' | 'center'" +
428
- " | …)} — auto-size the text into a box, keeping its anchored edge. The box is remembered: changing content later refits (shrinks, or grows back to maxFontSize); modify_item {fit: {...}} sets a new box; {fit: null} stops fitting. The result's textFit says {fontSize, lines, fits}; fits:false means it overflows even at minFontSize. Studios without auto-fit say so in textFit. Every export's fidelity then lists text_overflow for any text past its box or off the frame — the check to run before a batch of variants.\n direction: 'auto' (default: the first strong letter decides, so Arabic and Hebrew need nothing) | 'ltr' | 'rtl' — the paragraph's base direction, which places neutrals like the fina" +
429
- "l period (aliases textDirection, dir). The result's direction says whether the studio applied it; a studio without RTL support lays text out left-to-right and says so. justification: 'right' is alignment, not direction.\n tabularFigures: true (or fontVariantNumeric: 'tabular-nums', or fontFeatures containing 'tnum') — fixed-width digits, so a counter, price or timer does not jitter as it changes. The result's tabularFigures.applied is measured on the current studio: true only when the layout moves a digit's advance; false with a reason (font-digits-already-equal: the font's digits are one width already, so nothing changes and steadyWidth is true; rtl; not-requested); 'unverified' on an ol" +
430
- "der studio that records the request but cannot measure it.\n strokePosition: 'outside' — the stroke sits entirely outside the letters (drawn under the fill at twice strokeWidth), so a thick caption / meme outline keeps the letterform; 'center' (default) is the ordinary stroke. The result's strokePosition says whether the studio applied it.\n- circle: Circular shape (properties: radius, color, strokeColor, strokeWidth)\n- star: Star shape (properties: radius1, radius2, points, color)\n radius1 is the OUTER radius and is what sizes the star; radius or\n width/height size it equally well. radius2 is read ONLY as a ratio to\n radius1 (inner/outer, default 0.4), so radius2 passed WITHOUT ra" +
431
- "dius1 is\n ignored completely and you get the default waist. innerRadiusRatio sets that\n same waist directly and needs no radii at all. points defaults to 5.\n A star is always radially symmetric — given width and height it uses the\n smaller — and the size you give is the radius BOX, the circle the tips\n touch. The visible bounds are never larger than that box and usually\n smaller — they fill it only when points is a multiple of 4.\n- rectangle: Rectangle (properties: width, height, color, cornerRadius)\n- triangle: Triangular shape (properties: width, height, color, angles, kind)\n Defaults to a 100x86 isosceles. For any OTHER triangle, do not hand-build it\n from a path — nam" +
432
- "e it: kind: 'right' | 'equilateral' | 'isosceles' |\n 'obtuse' | 'acute' | 'scalene', or give exact interior angles in degrees,\n angles: [30, 60] (the third is inferred) or [30, 60, 90]. Angles that nearly\n sum to 180 are normalized, so rounding is safe. With angles or kind the\n triangle scales UNIFORMLY into the width x height box — the angles are fixed\n and win, so one axis comes out smaller than requested. That is the only way\n to keep a 'right' triangle actually right.\n An angle set the engine cannot use — fewer than two entries, or any angle\n outside 0.5-179.5 degrees — falls back to the default isosceles SILENTLY,\n with no error. If you asked for a right triangle and" +
433
- " got an isosceles one,\n the angles were rejected.\n- polygon: Regular polygon with N sides (properties: sides, radius, color)\n points additionally accepts an explicit VERTEX LIST — [[x, y], ...] or\n [{x, y}, ...], three or more, in canvas coordinates — which builds that\n exact, possibly IRREGULAR polygon and ignores sides. Reach for this before\n path when the outline is straight-edged. A plain number in points is not a\n vertex list and falls through to sides (default 6).\n In the vertex-list form the coordinates are ABSOLUTE canvas coordinates and\n position is ignored — the polygon lands where its points say, not where you\n positioned it. Offset the points yourself, or use" +
434
- " sides + radius + position.\n- ellipse: Oval shape (properties: width, height, color) — defaults to 100x60\n- path: Custom path with segments or SVG data (properties: segments, pathData, strokeColor, fillColor, closed, smooth)\n color on a path: a CLOSED path (closed:true, or pathData ending in Z) is FILLED with it; an OPEN path is STROKED with it. Pass fillColor / strokeColor to say exactly which.\n- line: Line between two points (properties: from, to, strokeColor, strokeWidth)\n- arc: Curved arc through three points (properties: from, through, to, strokeColor)\n- pentagon: Regular 5-sided polygon (properties: radius, color)\n- hexagon: Regular 6-sided polygon (properties: radius, color)" +
435
- "\n- diamond: 4-sided diamond/rhombus (properties: radius, color)\n- arrow: Right-pointing block arrow (properties: width, height, color)\n- heart: Heart shape (properties: width, height, color) — defaults to 80x80\n- disk: Filled disk — the region inside a circle (properties: radius, color)\n- circle-outline: The circle CURVE — stroked, no interior (properties: radius,\n strokeColor, strokeWidth)\n- arrow-right: Alias for arrow — the same definition under the name the shape\n grids use. Identical output and the same width, height and color; both are\n listed because both are accepted.\n\nSPEECH AND ANNOTATION SHAPES — the engine draws these; use one instead of\nhand-building an outline " +
436
- "from a path. All take width, height and color, and the\ndescriptions below are the engine's own. Three further parameters are each\nread by six of these eight shapes, and THE THREE SIXES ARE NOT THE SAME SIX —\na shape missing from a list ignores that parameter silently, so check before\nyou pass it:\n cornerRadius — speech-bubble, speech-bubble-pointed, comment-box,\n callout-box, double-bubble, quote-bubble\n tailDirection (bottom-left | bottom-right | bottom | top-left | top-right |\n top | left | right) — speech-bubble, speech-bubble-square,\n speech-bubble-pointed, thought-bubble, comment-box, callout-box\n tailSize (0-1, default 0.35) — speech-bubble, speech-bubble-square" +
437
- ",\n speech-bubble-pointed, comment-box, callout-box, double-bubble\n- speech-bubble: Classic round speech bubble with triangular tail.\n- speech-bubble-square: Rectangular speech bubble with sharp corners and\n pointed tail.\n- speech-bubble-pointed: Tooltip-style bubble with arrow pointer (tail defaults\n to \"bottom\").\n- thought-bubble: Cloud-like thought bubble with trailing dots.\n- comment-box: Modern chat message bubble with high corner radius.\n- callout-box: Annotation callout with narrow arrow (tail defaults to \"left\").\n- double-bubble: Two overlapping bubbles for conversation (properties:\n secondaryColor).\n- quote-bubble: Stylized bubble with quotation marks.\n\nPATH" +
438
- " FOR CUSTOM SHAPES:\nUse itemType: \"path\" with either:\n- segments: Array of [x,y] points for polygons, e.g. [[100,100], [200,50], [300,100]]\n- pathData: SVG path string for curves, e.g. \"M 100 100 C 150 50, 250 50, 300 100 Z\"\n\nADVANCED STYLING (NEW):\nAll shapes support these additional properties:\n- No fill (stroke-only outlines, line art): fillColor: null, 'transparent' or 'none', with a strokeColor and strokeWidth.\n- fillOpacity (0..1): the solid fill's own alpha, apart from opacity (which fades the stroke too) — on text, fillOpacity: 0.3 with a strokeColor is a see-through letter with a solid outline. Not applied to a gradient fill. The result's fillOpacity says whether the s" +
439
- "tudio applied it (an older studio ignores the key and says so).\n- Gradients: color can be an object: {type: \"linear\"|\"radial\", stops: [{color: \"#fff\", offset: 0}, {color: \"#000\", offset: 1}], origin: [x,y], destination: [x,y]}. Without origin / destination a radial gradient is centred on the item's own bounds and reaches its right edge, wherever anchor put it.\n- Shadows: shadowColor, shadowBlur (pixels), shadowOffset: [x, y]\n- Blend modes: blendMode: \"multiply\"|\"screen\"|\"overlay\"|\"darken\"|\"lighten\"|etc.\n- Opacity: opacity (0-1)\n\nEXAMPLES:\n- \"Create red text saying HELLO\" → itemType: \"text\", properties: {content: \"HELLO\", color: \"#ef4444\", fontSize: 48}\n- " +
440
- "\"Add a blue circle\" → itemType: \"circle\", properties: {radius: 50, color: \"#3b82f6\"}\n- \"Create a 5-pointed gold star\" → itemType: \"star\", properties: {radius1: 60, radius2: 30, points: 5, color: \"#fbbf24\"}\n- \"Draw a heart\" → itemType: \"path\", properties: {pathData: \"M 300 350 C 300 300, 250 250, 200 250 C 150 250, 100 300, 100 350 C 100 450, 300 550, 300 550 C 300 550, 500 450, 500 350 C 500 300, 450 250, 400 250 C 350 250, 300 300, 300 350 Z\", fillColor: \"#ef4444\"}\n- \"Circle with radial gradient\" → itemType: \"circle\", properties: {radius: 50, color: {type: \"radial\", stops: [{color: \"#ff6b6b\", offset: 0}, {color: \"#c0392b\", offset: 1}]}}\n- \"Rectangle with" +
441
- " shadow\" → itemType: \"rectangle\", properties: {width: 100, height: 50, color: \"#3b82f6\", shadowColor: \"rgba(0,0,0,0.5)\", shadowBlur: 10, shadowOffset: [5, 5]}\n\nITEM PROVENANCE:\nItems on the canvas can originate from multiple sources — all become first-class items with itemIds:\n- pinepaper_create_item: Direct shape creation (this tool)\n- pinepaper_import_svg: SVG file import → becomes a full item\n- pinepaper_import_image: Raster image import → becomes a full item\n- pinepaper_import_asset: Pre-made asset import → becomes a full item\nAll imported items participate in the relation and animation systems just like natively created items.\n\nRENDER-TIME SURFACES — 'shader' and 'fie" +
442
- "ld':\nThese are not shapes. A shape is built as an item and drawn from its geometry; a\nsurface has no geometry — it is evaluated, per pixel or per sample, as each frame\nis drawn. Use them for things a vector cannot be: an open sea, a drifting field\nof marks, a lit material.\n- shader: pick one of water | liquid_metal | heatmap | gem_smoke, give it width\n and height, and set behind:true to place it UNDER the vector art — which is\n what scenery wants, a sea the boats float ON rather than a sheen over them.\n- field: xExpr/yExpr/count draw many marks from one expression, re-evaluated\n every frame against t. rExpr and aExpr vary size and alpha per mark.\nBoth draw fully in a cloud ren" +
443
- "der; on a local canvas they stand in as a flat\nplate of their own colour so the layout still reads.\n\nLIFETIMES — bornAt and ttl, on any item:\nThe only way to build a piece that CUTS between shots. Without them every item\nis on screen for the whole render, which is what makes a long piece read as one\ncrowded frame instead of a sequence.\n\nWORKFLOW TIP:\nAfter creating items, use pinepaper_add_relation to animate them (e.g., orbits, follows, attached_to).\nFor glossy 3D spheres, use pinepaper_create_glossy_sphere instead. For diagonal stripes, use pinepaper_create_diagonal_stripes."
427
+ " the sibling\nlabelPosition spelling is also accepted, label.position wins.\nLabelling a text item gives you TWO text items in the group — the body and the\nlabel. That is what you want for a caption; it is a surprise if you expected\nthe label to replace the content.\n\n- text: Text content with font styling (properties: content, fontSize, color, fontFamily, justification, fontWeight, fontStyle, leading, lineHeight, letterSpacing, wordSpacing, textTransform, textDecoration)\n fontWeight: 400 / 700 / 'bold'. fontStyle: 'italic'. leading: line spacing in px; lineHeight: a multiple of fontSize (<= 4) or px. Multi-line content splits on \n.\n Current studio: wordSpacing (px, or a CSS length" +
428
+ " in px / em; it animates), textTransform ('uppercase' / 'lowercase' / 'capitalize' / 'none'), textDecoration ('underline' / 'line-through' / 'overline', combinable as 'underline overline'; 'none' clears) and textAlign: 'justify' (every line but the last stretched at its spaces) are drawn, on create, modify and keyframes; the result's typography reads back what the item holds. An older studio does not draw them, and its result says so (typography.applied: false; keyframes name them in ignoredProperties).\n fit: {maxWidth, maxHeight?, minFontSize? (8), maxFontSize? (current), wrap? (true), hold? ('top' | 'center' | …)} — auto-size the text into a box, keeping its anchored edge. The box is r" +
429
+ "emembered: changing content later refits (shrinks, or grows back to maxFontSize); modify_item {fit: {...}} sets a new box; {fit: null} stops fitting. The result's textFit says {fontSize, lines, fits}; fits:false means it overflows even at minFontSize. Studios without auto-fit say so in textFit. Every export's fidelity then lists text_overflow for any text past its box or off the frame — the check to run before a batch of variants.\n direction: 'auto' (default: the first strong letter decides, so Arabic and Hebrew need nothing) | 'ltr' | 'rtl' — the paragraph's base direction, which places neutrals like the final period (aliases textDirection, dir). The result's direction says whether the " +
430
+ "studio applied it; a studio without RTL support lays text out left-to-right and says so. justification: 'right' is alignment, not direction.\n tabularFigures: true (or fontVariantNumeric: 'tabular-nums', or fontFeatures containing 'tnum') — fixed-width digits, so a counter, price or timer does not jitter as it changes. The result's tabularFigures.applied is measured on the current studio: true only when the layout moves a digit's advance; false with a reason (font-digits-already-equal: the font's digits are one width already, so nothing changes and steadyWidth is true; rtl; not-requested); 'unverified' on an older studio that records the request but cannot measure it.\n strokePosition: '" +
431
+ "outside' — the stroke sits entirely outside the letters (drawn under the fill at twice strokeWidth), so a thick caption / meme outline keeps the letterform; 'center' (default) is the ordinary stroke. The result's strokePosition says whether the studio applied it.\n- circle: Circular shape (properties: radius, color, strokeColor, strokeWidth)\n- star: Star shape (properties: radius1, radius2, points, color)\n radius1 is the OUTER radius and is what sizes the star; radius or\n width/height size it equally well. radius2 is read ONLY as a ratio to\n radius1 (inner/outer, default 0.4), so radius2 passed WITHOUT radius1 is\n ignored completely and you get the default waist. innerRadiusRatio " +
432
+ "sets that\n same waist directly and needs no radii at all. points defaults to 5.\n A star is always radially symmetric — given width and height it uses the\n smaller — and the size you give is the radius BOX, the circle the tips\n touch. The visible bounds are never larger than that box and usually\n smaller — they fill it only when points is a multiple of 4.\n- rectangle: Rectangle (properties: width, height, color, cornerRadius)\n- triangle: Triangular shape (properties: width, height, color, angles, kind)\n Defaults to a 100x86 isosceles. For any OTHER triangle, do not hand-build it\n from a path — name it: kind: 'right' | 'equilateral' | 'isosceles' |\n 'obtuse' | 'acute' | 'sc" +
433
+ "alene', or give exact interior angles in degrees,\n angles: [30, 60] (the third is inferred) or [30, 60, 90]. Angles that nearly\n sum to 180 are normalized, so rounding is safe. With angles or kind the\n triangle scales UNIFORMLY into the width x height box — the angles are fixed\n and win, so one axis comes out smaller than requested. That is the only way\n to keep a 'right' triangle actually right.\n An angle set the engine cannot use — fewer than two entries, or any angle\n outside 0.5-179.5 degrees — falls back to the default isosceles SILENTLY,\n with no error. If you asked for a right triangle and got an isosceles one,\n the angles were rejected.\n- polygon: Regular polygon" +
434
+ " with N sides (properties: sides, radius, color)\n points additionally accepts an explicit VERTEX LIST — [[x, y], ...] or\n [{x, y}, ...], three or more, in canvas coordinates — which builds that\n exact, possibly IRREGULAR polygon and ignores sides. Reach for this before\n path when the outline is straight-edged. A plain number in points is not a\n vertex list and falls through to sides (default 6).\n In the vertex-list form the coordinates are ABSOLUTE canvas coordinates and\n position is ignored — the polygon lands where its points say, not where you\n positioned it. Offset the points yourself, or use sides + radius + position.\n- ellipse: Oval shape (properties: width, height, " +
435
+ "color) — defaults to 100x60\n- path: Custom path with segments or SVG data (properties: segments, pathData, strokeColor, fillColor, closed, smooth)\n color on a path: a CLOSED path (closed:true, or pathData ending in Z) is FILLED with it; an OPEN path is STROKED with it. Pass fillColor / strokeColor to say exactly which.\n- line: Line between two points (properties: from, to, strokeColor, strokeWidth)\n- arc: Curved arc through three points (properties: from, through, to, strokeColor)\n- pentagon: Regular 5-sided polygon (properties: radius, color)\n- hexagon: Regular 6-sided polygon (properties: radius, color)\n- diamond: 4-sided diamond/rhombus (properties: radius, color)\n- arrow: Righ" +
436
+ "t-pointing block arrow (properties: width, height, color)\n- heart: Heart shape (properties: width, height, color) — defaults to 80x80\n- disk: Filled disk — the region inside a circle (properties: radius, color)\n- circle-outline: The circle CURVE — stroked, no interior (properties: radius,\n strokeColor, strokeWidth)\n- arrow-right: Alias for arrow — the same definition under the name the shape\n grids use. Identical output and the same width, height and color; both are\n listed because both are accepted.\n\nSPEECH AND ANNOTATION SHAPES — the engine draws these; use one instead of\nhand-building an outline from a path. All take width, height and color, and the\ndescriptions below are " +
437
+ "the engine's own. Three further parameters are each\nread by six of these eight shapes, and THE THREE SIXES ARE NOT THE SAME SIX —\na shape missing from a list ignores that parameter silently, so check before\nyou pass it:\n cornerRadius — speech-bubble, speech-bubble-pointed, comment-box,\n callout-box, double-bubble, quote-bubble\n tailDirection (bottom-left | bottom-right | bottom | top-left | top-right |\n top | left | right) — speech-bubble, speech-bubble-square,\n speech-bubble-pointed, thought-bubble, comment-box, callout-box\n tailSize (0-1, default 0.35) — speech-bubble, speech-bubble-square,\n speech-bubble-pointed, comment-box, callout-box, double-bubble\n- speech" +
438
+ "-bubble: Classic round speech bubble with triangular tail.\n- speech-bubble-square: Rectangular speech bubble with sharp corners and\n pointed tail.\n- speech-bubble-pointed: Tooltip-style bubble with arrow pointer (tail defaults\n to \"bottom\").\n- thought-bubble: Cloud-like thought bubble with trailing dots.\n- comment-box: Modern chat message bubble with high corner radius.\n- callout-box: Annotation callout with narrow arrow (tail defaults to \"left\").\n- double-bubble: Two overlapping bubbles for conversation (properties:\n secondaryColor).\n- quote-bubble: Stylized bubble with quotation marks.\n\nPATH FOR CUSTOM SHAPES:\nUse itemType: \"path\" with either:\n- segments: Array of " +
439
+ "[x,y] points for polygons, e.g. [[100,100], [200,50], [300,100]]\n- pathData: SVG path string for curves, e.g. \"M 100 100 C 150 50, 250 50, 300 100 Z\"\n\nADVANCED STYLING (NEW):\nAll shapes support these additional properties:\n- No fill (stroke-only outlines, line art): fillColor: null, 'transparent' or 'none', with a strokeColor and strokeWidth.\n- fillOpacity (0..1): the solid fill's own alpha, apart from opacity (which fades the stroke too) — on text, fillOpacity: 0.3 with a strokeColor is a see-through letter with a solid outline. Not applied to a gradient fill. The result's fillOpacity says whether the studio applied it (an older studio ignores the key and says so).\n- Gradients: c" +
440
+ "olor can be an object: {type: \"linear\"|\"radial\", stops: [{color: \"#fff\", offset: 0}, {color: \"#000\", offset: 1}], origin: [x,y], destination: [x,y]}. Without origin / destination a radial gradient is centred on the item's own bounds and reaches its right edge, wherever anchor put it.\n- Shadows: shadowColor, shadowBlur (pixels), shadowOffset: [x, y]\n- Blend modes: blendMode: \"multiply\"|\"screen\"|\"overlay\"|\"darken\"|\"lighten\"|etc.\n- Opacity: opacity (0-1)\n\nEXAMPLES:\n- \"Create red text saying HELLO\" → itemType: \"text\", properties: {content: \"HELLO\", color: \"#ef4444\", fontSize: 48}\n- \"Add a blue circle\" → itemType: \"circle\", properties: {radius: 50, color: \"" +
441
+ "#3b82f6\"}\n- \"Create a 5-pointed gold star\" → itemType: \"star\", properties: {radius1: 60, radius2: 30, points: 5, color: \"#fbbf24\"}\n- \"Draw a heart\" → itemType: \"path\", properties: {pathData: \"M 300 350 C 300 300, 250 250, 200 250 C 150 250, 100 300, 100 350 C 100 450, 300 550, 300 550 C 300 550, 500 450, 500 350 C 500 300, 450 250, 400 250 C 350 250, 300 300, 300 350 Z\", fillColor: \"#ef4444\"}\n- \"Circle with radial gradient\" → itemType: \"circle\", properties: {radius: 50, color: {type: \"radial\", stops: [{color: \"#ff6b6b\", offset: 0}, {color: \"#c0392b\", offset: 1}]}}\n- \"Rectangle with shadow\" → itemType: \"rectangle\", properties: {width: 100, height: 50, color:" +
442
+ " \"#3b82f6\", shadowColor: \"rgba(0,0,0,0.5)\", shadowBlur: 10, shadowOffset: [5, 5]}\n\nITEM PROVENANCE:\nItems on the canvas can originate from multiple sources — all become first-class items with itemIds:\n- pinepaper_create_item: Direct shape creation (this tool)\n- pinepaper_import_svg: SVG file import → becomes a full item\n- pinepaper_import_image: Raster image import → becomes a full item\n- pinepaper_import_asset: Pre-made asset import → becomes a full item\nAll imported items participate in the relation and animation systems just like natively created items.\n\nRENDER-TIME SURFACES — 'shader' and 'field':\nThese are not shapes. A shape is built as an item and drawn from its geome" +
443
+ "try; a\nsurface has no geometry — it is evaluated, per pixel or per sample, as each frame\nis drawn. Use them for things a vector cannot be: an open sea, a drifting field\nof marks, a lit material.\n- shader: pick one of water | liquid_metal | heatmap | gem_smoke, give it width\n and height, and set behind:true to place it UNDER the vector art — which is\n what scenery wants, a sea the boats float ON rather than a sheen over them.\n- field: xExpr/yExpr/count draw many marks from one expression, re-evaluated\n every frame against t. rExpr and aExpr vary size and alpha per mark.\nBoth draw fully in a cloud render; on a local canvas they stand in as a flat\nplate of their own colour so the" +
444
+ " layout still reads.\n\nLIFETIMES — bornAt and ttl, on any item:\nThe only way to build a piece that CUTS between shots. Without them every item\nis on screen for the whole render, which is what makes a long piece read as one\ncrowded frame instead of a sequence.\n\nWORKFLOW TIP:\nAfter creating items, use pinepaper_add_relation to animate them (e.g., orbits, follows, attached_to).\nFor glossy 3D spheres, use pinepaper_create_glossy_sphere instead. For diagonal stripes, use pinepaper_create_diagonal_stripes."
444
445
 
445
446
 
446
447
 
@@ -1162,8 +1163,8 @@ Use pinepaper_clear_canvas instead of deleting items one by one.`,
1162
1163
  openWorldHint: true,
1163
1164
  },
1164
1165
  description: ("Import a raster image onto the canvas from an http(s) URL, a data: URL, or a LOCAL FILE PATH (the server reads the file). Returns an itemId for the imported image.\n\nUSE WHEN:\n- User wants to add a photo, PNG, JPG, or other raster image\n- Importing images from URLs\n- Adding backgrounds or reference images\n- User provides an image URL\n\nIMPORTANT - IMPORTED IMAGES ARE FULL ITEMS:\nThe imported image becomes a first-class item in PinePaper's registry. It gets an itemId and\ncan participate in the full relation and animation system — you can orbit it, attach things to it,\napply masks, animate it with keyframes, etc., just like any natively created shape.\n\nOPTIONAL MASK:\nApply a mask" +
1165
- " shape during import to clip the image:\n- circle: Circular crop\n- rounded: Rounded rectangle crop\n- hexagon: Hexagonal crop\n- star: Star-shaped crop\n\nSIZE CONSTRAINTS:\nUse maxWidth/maxHeight to constrain the image dimensions while preserving aspect ratio.\n- maxEdge: the longest edge the studio STORES the image at (px), or 'native' to keep every pixel. The result says when it was stored smaller.\n- nativeSize: true places the image at its source footprint even if it was stored smaller.\n- smoothing: 'off' (nearest-neighbour, for pixel art) | 'low' (default) | 'medium' | 'high'.\n- shadowColor / shadowBlur / shadowOffset: a drop shadow on the placed image, the same keys pinepaper_mod" +
1166
- "ify_item takes.\n- name: a name for the image, so a batch patch or any tool that takes an item can address it by name instead of its id.\n\nEXAMPLES:\n- Import photo: url: \"https://example.com/photo.jpg\"\n- Import with mask: url: \"https://example.com/avatar.png\", mask: \"circle\"\n- Import at position: url: \"https://example.com/bg.jpg\", position: {x: 400, y: 300}, maxWidth: 800"
1166
+ " shape during import to clip the image:\n- circle: Circular crop\n- rounded: Rounded rectangle crop\n- hexagon: Hexagonal crop\n- star: Star-shaped crop\n\nSIZE CONSTRAINTS:\nUse maxWidth/maxHeight to constrain the image dimensions while preserving aspect ratio.\n- maxEdge: the longest edge the studio STORES the image at (px), or 'native' to keep every pixel. The result says when it was stored smaller.\n- nativeSize: true places the image at its source footprint even if it was stored smaller.\n- smoothing: 'off' (nearest-neighbour, for pixel art) | 'low' (default) | 'medium' | 'high'. Chromium draws 'low', 'medium' and 'high' the same; the visible choice is 'off' or not.\n- shadowColor / s" +
1167
+ "hadowBlur / shadowOffset: a drop shadow on the placed image, the same keys pinepaper_modify_item takes.\n- name: a name for the image, so a batch patch or any tool that takes an item can address it by name instead of its id.\n\nEXAMPLES:\n- Import photo: url: \"https://example.com/photo.jpg\"\n- Import with mask: url: \"https://example.com/avatar.png\", mask: \"circle\"\n- Import at position: url: \"https://example.com/bg.jpg\", position: {x: 400, y: 300}, maxWidth: 800"
1167
1168
 
1168
1169
 
1169
1170
 
@@ -1227,7 +1228,7 @@ Use pinepaper_clear_canvas instead of deleting items one by one.`,
1227
1228
  smoothing: {
1228
1229
  type: 'string',
1229
1230
  enum: ['off', 'low', 'medium', 'high'],
1230
- description: "Resampling when the image is scaled. 'off' = nearest-neighbour, hard pixel edges — use it for pixel art. Default 'low'. Change it later with pinepaper_modify_item {smoothing}.",
1231
+ description: "Resampling when the image is scaled. 'off' = nearest-neighbour, hard pixel edges — use it for pixel art. Default 'low'. In Chromium 'low', 'medium' and 'high' draw the same (its filter is already near the exact average, measured within 0.6 RMSE of each other), so the visible choice is 'off' or not. Change it later with pinepaper_modify_item {smoothing}.",
1231
1232
  },
1232
1233
  shadowColor: { type: 'string', description: 'Drop shadow colour (hex, rgb(a) or a name), applied to the placed image — the mask group when masked. result.shadow says whether it applied.' },
1233
1234
  shadowBlur: { type: 'number', description: 'Drop shadow blur radius in px.' },
@@ -1683,7 +1684,17 @@ EXAMPLE:
1683
1684
  openWorldHint: false,
1684
1685
  },
1685
1686
  description: ("Manage the pp:event channel — the backbone of EVENT-DRIVEN SCENE CHAINS (FxTool S11). An event is a fireable channel; relations with an event as their source run when it fires. This is how you author a LONG scene as a graph of timed beats instead of a keyframe track.\n\nACTIONS:\n- create: make an event. { name (required), payloadType?, x?, y? } → returns { eventId }. Use the eventId as the sourceId of on_event_* relations (pinepaper_add_relation).\n- pulse: fire an event now. { eventId (required), payload? }. Kicks off a chain.\n\nTYPICAL SCENE CHAIN:\n1. pinepaper_event create → e0, e1, e2 (one per beat).\n2. Chain them: add_relation on_event_fire_after from e0→e1 (params.delay, timeline" +
1686
- ":'canvas'), e1→e2, …\n3. Give each beat reactions: on_event_add_relation (spawn an orbit/path), on_event_set_color, on_event_set_property, on_event_set_visibility (all via pinepaper_add_relation, sourceId=the event).\n4. pinepaper_event pulse e0 to start. Canvas-timed fires are scrub/loop/replay-stable.\n\nRETURNS: create → { success, action, eventId }; pulse → { success, action, eventId }."
1687
+ ":'canvas'), e1→e2, …\n3. Give each beat reactions: on_event_add_relation (spawn an orbit/path), on_event_set_color, on_event_set_property, on_event_set_visibility (all via pinepaper_add_relation, sourceId=the event).\n4. pinepaper_event pulse e0 to start. Canvas-timed fires are scrub/loop/replay-stable.\n\nRULES (contraptions): a rule runs when an event fires: { action: 'add_contraption', sourceEvent, actions, condition?, thenTrigger?, elseTrigger?, contraptionId? }. sourceEvent is an eventId, any name you pulse, or 'state:<key>' when that variable changes. actions: setVariable { key, value? }, incrementVariable { key, by? }, toggleVariable { key }, modifyItem { targetId, properties: { vis" +
1688
+ "ible, opacity, fillColor, strokeColor, content, position } }. list_contraptions shows each rule's fireCount, which is how to tell it ran; remove_contraption takes contraptionId.\n- For a video, use on_event_* relations (above): a rule's changes to an item stay, nothing rewinds them, so another export starts from the changed scene. Rules suit interactive state.\n- pinepaper_clear_canvas removes rules. They are not saved with the scene.\n\nVARIABLES: set_variable { key, value }, get_variable { key, fallback? }, increment_variable { key, by? }, each with an optional scope. They live in the page's storage, so they survive pinepaper_clear_canvas: set the ones a scene reads when it starts. Nothi" +
1689
+ "ng private belongs in them.\n\nBOUND TEXT: bind_text { itemId, template: \"Score: {score}\" } keeps a text item showing the variables it names.\n\nA studio without the contraption engine refuses these actions, naming the method it lacks.\n\nRETURNS: create → { success, action, eventId }; pulse → { success, action, eventId }; add_contraption → { success, action, contraptionId, warning? }."
1690
+
1691
+
1692
+
1693
+
1694
+
1695
+
1696
+
1697
+
1687
1698
 
1688
1699
 
1689
1700
 
@@ -1699,7 +1710,7 @@ EXAMPLE:
1699
1710
  type: 'object',
1700
1711
  properties: {
1701
1712
  at: { description: 'create: fire this event at these scene times (seconds) — a timed trigger that runs on the timeline and in video exports. The widget player does not run timed triggers.', oneOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' } }] },
1702
- action: { type: 'string', enum: ['create', 'pulse'], description: "'create' a pp:event · 'pulse' it to fire" },
1713
+ action: { type: 'string', enum: ['create', 'pulse', 'add_contraption', 'remove_contraption', 'list_contraptions', 'set_variable', 'get_variable', 'increment_variable', 'bind_text'], description: "'create' a pp:event · 'pulse' it to fire · 'add_contraption' / 'remove_contraption' / 'list_contraptions' a rule · 'set_variable' / 'get_variable' / 'increment_variable' a variable · 'bind_text' a text item to variables" },
1703
1714
  name: { type: 'string', description: "Event name — required for action 'create'" },
1704
1715
  payloadType: { type: 'string', description: "Payload type label (default 'Pulse') — action 'create'" },
1705
1716
  x: { type: 'number', description: 'Canvas x for the event marker — action create' },
@@ -1709,6 +1720,57 @@ EXAMPLE:
1709
1720
  anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'object' }, { type: 'array' }],
1710
1721
  description: "Optional value forwarded to listeners — action 'pulse'",
1711
1722
  },
1723
+ contraptionId: { type: 'string', description: "The rule's id: optional for add_contraption, required for remove_contraption" },
1724
+ sourceEvent: { type: 'string', description: "add_contraption: what runs the rule: an eventId, any name you pulse, or 'state:<key>' when that variable changes" },
1725
+ condition: {
1726
+ type: 'object',
1727
+ description: 'add_contraption: the rule runs its actions only when this holds',
1728
+ properties: {
1729
+ key: { type: 'string' },
1730
+ op: { type: 'string', enum: ['==', '===', '!=', '>', '>=', '<', '<=', 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'truthy', 'falsy', 'in'], description: "Default '=='" },
1731
+ value: { description: 'What to compare with' },
1732
+ scope: { type: 'string' },
1733
+ source: { type: 'string', enum: ['state', 'payload'], description: "Read key from a variable (default) or from the event's payload" },
1734
+ },
1735
+ },
1736
+ actions: {
1737
+ type: 'array',
1738
+ description: 'add_contraption: what the rule does',
1739
+ items: {
1740
+ type: 'object',
1741
+ properties: {
1742
+ type: { type: 'string', enum: ['setVariable', 'incrementVariable', 'toggleVariable', 'modifyItem'] },
1743
+ key: { type: 'string', description: 'The variable' },
1744
+ value: { description: 'setVariable: left out, the event payload' },
1745
+ by: { type: 'number', description: 'incrementVariable: default 1' },
1746
+ scope: { type: 'string' },
1747
+ targetId: { type: 'string', description: 'modifyItem: the item' },
1748
+ properties: {
1749
+ type: 'object',
1750
+ description: 'modifyItem: only these',
1751
+ properties: {
1752
+ visible: { type: 'boolean' },
1753
+ opacity: { type: 'number' },
1754
+ fillColor: { type: 'string' },
1755
+ strokeColor: { type: 'string' },
1756
+ content: { type: 'string' },
1757
+ position: { description: '[x, y] or {x, y}' },
1758
+ },
1759
+ },
1760
+ },
1761
+ required: ['type'],
1762
+ },
1763
+ },
1764
+ thenTrigger: { type: 'object', description: 'add_contraption: the event to fire after the actions', properties: { eventId: { type: 'string' }, delay: { type: 'number', description: 'Seconds' }, payload: {} }, required: ['eventId'] },
1765
+ elseTrigger: { type: 'object', description: 'add_contraption: the event to fire when the condition does not hold', properties: { eventId: { type: 'string' }, delay: { type: 'number', description: 'Seconds' }, payload: {} }, required: ['eventId'] },
1766
+ enabled: { type: 'boolean', description: 'add_contraption: false registers it switched off' },
1767
+ key: { type: 'string', description: 'The variable, for set_variable, get_variable and increment_variable' },
1768
+ value: { description: 'set_variable: its new value' },
1769
+ by: { type: 'number', description: 'increment_variable: default 1' },
1770
+ fallback: { description: 'get_variable: returned when the variable is not set' },
1771
+ scope: { type: 'string', description: "Variable namespace (default 'default')" },
1772
+ itemId: { type: 'string', description: 'bind_text: the text item' },
1773
+ template: { type: 'string', description: 'bind_text: the text, with {variable} where a value goes: "Score: {score}"' },
1712
1774
  },
1713
1775
  required: ['action'],
1714
1776
  },
@@ -3423,8 +3485,8 @@ EXAMPLE: { itemId: 'item_7', color: '#00b140', threshold: 60, smoothing: 12 }`,
3423
3485
  openWorldHint: false,
3424
3486
  },
3425
3487
  description: ("Audit the LIVE scene's relational structure (FxTool S12-E2) — read-only, nothing is changed. Answers \"is this scene composed with relations, or just a pile of absolutely-positioned items?\" and proposes fixes.\n\nRETURNS:\n- relations: every relation bound — [{ from, fromName?, to, type, params }], the first 200 (relationCount says how many when there are more). Relations bound by a name pattern (\"dot-*\") are listed once per group in edit.groups instead (groupedRelations counts them). Use it to find the relation to change, then a batch relation op with update: true.\n- customRelations: each pinepaper_register_custom_relation definition the scene uses (name, params; computeFunction / app" +
3426
- "lyFunction with withSource: true). registeredUnused names any registered that no relation uses.\n- edit: the exact ops that change this scene (per relation, per group with add / remove member ops, per custom motion). With only: 'edit' the reply is just this section.\n- density: { itemCount, relationalItemCount, relationalDensity (0..1 — fraction of items touching the relation graph), edgeCount, componentCount }. A low relationalDensity means items are placed by hardcoded coordinates and won't hold together if something moves.\n- suggestions: structural relations for unrelated item pairs that ALREADY sit in a structural configuration — [{ from, to, type (on_top_of/below/beside/inside/center" +
3427
- "ed_on/aligned_with), params, confidence }], ranked by confidence. Apply the good ones with pinepaper_add_relation to make the layout robust.\n\nUSE WHEN:\n- After building a scene, to check it's relation-driven rather than coordinate-driven\n- To discover which items SHOULD be related (labels on shapes, a badge on a card, stacked elements) but aren't\n- Before export, as a composition-quality gate\n\nPARAMS: { eps? (bounds-match tolerance px, default 4), cap? (max suggestions, default 20), only? ('edit'), withSource? (custom relation source, default false) }"
3488
+ "lyFunction with withSource: true). registeredUnused names any registered that no relation uses.\n- edit: the exact ops that change this scene (per relation, per group with add / remove member ops; per custom motion with withSource: true, since the call that redefines one needs its source). With only: 'edit' the reply is just this section.\n- density: { itemCount, relationalItemCount, relationalDensity (0..1 — fraction of items touching the relation graph), edgeCount, componentCount }. A low relationalDensity means items are placed by hardcoded coordinates and won't hold together if something moves.\n- suggestions: structural relations for unrelated item pairs that ALREADY sit in a structur" +
3489
+ "al configuration — [{ from, to, type (on_top_of/below/beside/inside/centered_on/aligned_with), params, confidence }], ranked by confidence. Apply the good ones with pinepaper_add_relation to make the layout robust.\n\nUSE WHEN:\n- After building a scene, to check it's relation-driven rather than coordinate-driven\n- To discover which items SHOULD be related (labels on shapes, a badge on a card, stacked elements) but aren't\n- Before export, as a composition-quality gate\n\nPARAMS: { eps? (bounds-match tolerance px, default 4), cap? (max suggestions, default 20), only? ('edit'), withSource? (custom relation source, default false) }"
3428
3490
 
3429
3491
 
3430
3492
 
@@ -3474,13 +3536,15 @@ EXAMPLE: { itemId: 'item_7', color: '#00b140', threshold: 60, smoothing: 12 }`,
3474
3536
  "tem.data, so the text can quote live state (\"Score: {score}\") instead of a literal fixed when you authored it.\n · on_event_increment (params: property, by) / on_event_toggle — counters and flip-flops.\n · on_event_fire_after (params: delay, timeline) — chain one beat to the next.\n · on_event_fire_if (params: predicate, event, else?) — the only reaction that TESTS graph state rather than mutating it. When the source fires it evaluates params.predicate and pulses the EVENT params.event if it holds (params.else if not); its target is not fired, so the visible change comes from a reaction on that second event. params.predicate is an ExpressionIR object, not a string: { op:'const', value" +
3475
3537
  " } · { op:'cmp', cmp: eq|neq|gt|gte|lt|lte, a, b } · { op:'logic', logic: and|or|not, args:[…] } · { op:'graph', read: data|active|visible, item, property } (data reads item.data[property]). A string predicate, or no event, is refused.\n · on_event_add_relation / on_event_remove_relation — the scene rewires itself.\n · on_event_play_sound (source = the EVENT, target null; params: sound, gain?, note?) — play a sound when the event fires: a catalogue name (effect, drum or instrument; aliases accepted) or a define_sfx spec; gain is a multiplier capped at full scale; note is for instruments (default C5). An unknown sound is refused with the catalogue. Wire a click sound as on_click_fire (but" +
3476
3538
  "ton → event) + on_event_play_sound (event → null). For a sound at a TIME, pinepaper_event create { at: seconds | [seconds] } fires the event on the timeline (and in video exports); a scene chain's cutSound puts one on every cut. LIMITS: the exported widget player does not run timed triggers, and a browser may block sound until the viewer's first click or key.\n · on_event_store_set / on_event_store_increment / restores_from — persistent state. The target is a store node whose id is the storage SCOPE, so two stores in a scene and two widgets on a page do not collide. restores_from is the read half and fires ONCE at load.\n- Proximity (pointer over an item): on_enter_* / on_exit_* mirror th" +
3477
- "e on_event_* reactions above (set_color, set_property, set_property_from_template, set_visibility, set_data, increment, toggle) — hover states without a single line of script.\n- Exclusive state: exclusive_group makes at most one member active (on_event_set_active is how you activate one — tabs, steppers, quiz cards; on_event_step_active moves the active member by relationOptions.delta, default +1, for next / previous arrows, and wraps unless wrap: false. Its groupName names the exclusive_group, default '_default'; items gives the step order). menubar_group is the same grouping WITHOUT the mutex.\n\nThere are 80 relation types and this list names the families rather than every member. For " +
3478
- "the live catalogue — what exists in THIS build, with definitions — call pinepaper_query_capabilities { kind: 'relation' }, which reads the engine's registry rather than a list written down here.\n\nRELATION COMPATIBILITY:\n- Universal relations (position-based) work with ALL item types including imported SVGs/images:\n orbits, follows, attached_to, maintains_distance, points_at, mirrors, parallax, bounds_to\n- morphs_to has type-specific behavior:\n Path→Path: point interpolation\n Circle→Circle: radius interpolation\n Text→Text: font blend\n Cross-type: particle denoising transition\n- group_morphs_to: pair-by-index morph between two paper.Groups (any two — graph vertices+edges, lett" +
3479
- "er collages, dashboard clusters). Path.Line children deform via endpoints; other children translate; excess children fade. Params: duration, hold, loop, easing (linear|easeIn|easeOut|easeInOut), deformLines.\n- moves_along_path: self-relation (targetId=null); item is driven along a path. Source the path EITHER from params.path (array of {x,y} or [x,y]) OR from params.equation (S10 B5) — a math curve the item TRAVERSES, sampled deterministically on the Expression IR: params.equation = { kind: function|parametric|fourier|preset, expr | xExpr/yExpr | harmonics | preset, min, max, samples, scale, cx, cy, flipY, warp } (same equation contract as pinepaper_equation_path, but origin is cx/cy, def" +
3480
- "ault 0). y is MATH-UP by default (flipY: true): on screen the curve is mirrored about cy, so a curve written in screen coordinates (y down) needs flipY: false. Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).\n\nGEOMETRIC CONSTRUCTION CONSTRAINTS (Layer 2) — the source item is RE-DERIVED every frame from its anchor item(s), so dragging an anchor updates the dependent live (GeoGebra-style), and the construction persists as relation graph data (edit it by editing relations, not by re-running code). Anchor A is the relation target; extra anchors ride in params:\n- is_midpoint_of: source = midpoint(target, params.other). params.othe" +
3481
- "r = id of the second endpoint (B).\n- lies_on_line: source held on the line target→params.other at fraction params.t (0=A, 1=B; default 0.5). params.other (B), params.t.\n- is_centroid_of: source = centroid(target, ...params.others). params.others = array of the remaining point ids.\n- is_circumcenter_of: source = circumcenter(target, params.other1, params.other2); holds position when the three anchors are collinear. params.other1 (B), params.other2 (C).\n- concentric_with: source stays centered on the target (shared center).\n- construction_reveal: self-relation (targetId=null); opacity 0→1 starting at params.revealAt (timeline seconds) over params.fadeIn seconds — loop- and scrub-correct" +
3482
- ". Pair with pinepaper_construction_sequence to reveal a figure one step at a time.\n\nPROCEDURAL / DETERMINISTIC BINDING (Expression IR — S10 G1) — bind a property to another property or to a math function of time. Pass params.signal:true to route through the pure, seed/frame-deterministic signal interpreter (replay-stable — same time → identical output); omit it for the per-frame compute fallback.\n- driven_by: source's sourceProperty = target's targetProperty * multiplier + offset (optionally clamped). sourceProperty ∈ x|y|rotation|opacity|scale|strokeWidth|fillColor|strokeColor; targetProperty ∈ x|y|rotation|opacity|scale. For fillColor/strokeColor the (clamped 0..1) driven value interp" +
3483
- "olates colorFrom→colorTo — so a relation drives COLOR. NEEDS a target (the property source). params: sourceProperty, targetProperty, multiplier, offset, clamp {min,max}, colorFrom, colorTo, signal. e.g. tint a dot by a planet's x: sourceProperty 'fillColor', targetProperty 'x', colorFrom '#0055ff', colorTo '#ff3300', signal true.\n- time_expression: self-relation (targetId=null); the source's params.property (x, y, rotation, opacity or scale) is driven by a formula of t (scene time) and v (params.baseValue). params: property (default 'y'), expression (e.g. 'sin(t*2)*50 + v'), baseValue, signal (true also evaluates it as a pure f(t) via the Expression IR). The formula grammar: " +
3539
+ "e on_event_* reactions above (set_color, set_property, set_property_from_template, set_visibility, set_data, increment, toggle) — hover states without a single line of script.\n- Exclusive state: exclusive_group makes at most one member active — tabs, steppers, quiz cards. MEMBERSHIP is pairwise: bind exclusive_group from one member to each other member with the same relationOptions.groupName (default '_default'); nothing else adds an item to the group. Being active has NO LOOK OF ITS OWN: give each member on_enter_set_property / on_exit_set_property (say opacity 1 / 0.3), with the member as both source and target, and those reactions draw the change, in exports too. A current studio start" +
3540
+ "s the group with its first member entered (relationOptions.initial: '<itemId>' picks another, 'none' starts with none), firing its on_enter reactions at t=0; an older studio starts with none entered, so every member shows its un-entered look until the first step. on_event_set_active activates its target. on_event_step_active moves the active member by relationOptions.delta (default +1) for next / previous arrows, wrapping unless wrap: false; a current studio runs it with no target, an older one needs a targetId (any item, e.g. one member) and is refused without one, and its relationOptions.items only sets the step order of items already in the group. menubar_group is the same grouping WITH" +
3541
+ "OUT the mutex.\n\nThere are 80 relation types and this list names the families rather than every member. For the live catalogue — what exists in THIS build, with definitions — call pinepaper_query_capabilities { kind: 'relation' }, which reads the engine's registry rather than a list written down here.\n\nRELATION COMPATIBILITY:\n- Universal relations (position-based) work with ALL item types including imported SVGs/images:\n orbits, follows, attached_to, maintains_distance, points_at, mirrors, parallax, bounds_to\n- morphs_to has type-specific behavior:\n Path→Path: point interpolation\n Circle→Circle: radius interpolation\n Text→Text: font blend\n Cross-type: particle denoising tran" +
3542
+ "sition\n- group_morphs_to: pair-by-index morph between two paper.Groups (any two — graph vertices+edges, letter collages, dashboard clusters). Path.Line children deform via endpoints; other children translate; excess children fade. Params: duration, hold, loop, easing (linear|easeIn|easeOut|easeInOut), deformLines.\n- moves_along_path: self-relation (targetId=null); item is driven along a path. Source the path EITHER from params.path (array of {x,y} or [x,y]) OR from params.equation (S10 B5) — a math curve the item TRAVERSES, sampled deterministically on the Expression IR: params.equation = { kind: function|parametric|fourier|preset, expr | xExpr/yExpr | harmonics | preset, min, max, sampl" +
3543
+ "es, scale, cx, cy, flipY, warp } (same equation contract as pinepaper_equation_path, but origin is cx/cy, default 0). y is MATH-UP by default (flipY: true): on screen the curve is mirrored about cy, so a curve written in screen coordinates (y down) needs flipY: false. Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).\n\nGEOMETRIC CONSTRUCTION CONSTRAINTS (Layer 2) — the source item is RE-DERIVED every frame from its anchor item(s), so dragging an anchor updates the dependent live (GeoGebra-style), and the construction persists as relation graph data (edit it by editing relations, not by re-running code). Anchor A is the relation " +
3544
+ "target; extra anchors ride in params:\n- is_midpoint_of: source = midpoint(target, params.other). params.other = id of the second endpoint (B).\n- lies_on_line: source held on the line target→params.other at fraction params.t (0=A, 1=B; default 0.5). params.other (B), params.t.\n- is_centroid_of: source = centroid(target, ...params.others). params.others = array of the remaining point ids.\n- is_circumcenter_of: source = circumcenter(target, params.other1, params.other2); holds position when the three anchors are collinear. params.other1 (B), params.other2 (C).\n- concentric_with: source stays centered on the target (shared center).\n- construction_reveal: self-relation (targetId=null); op" +
3545
+ "acity 0→1 starting at params.revealAt (timeline seconds) over params.fadeIn seconds — loop- and scrub-correct. Pair with pinepaper_construction_sequence to reveal a figure one step at a time.\n\nPROCEDURAL / DETERMINISTIC BINDING (Expression IR — S10 G1) — bind a property to another property or to a math function of time. Pass params.signal:true to route through the pure, seed/frame-deterministic signal interpreter (replay-stable — same time → identical output); omit it for the per-frame compute fallback.\n- driven_by: source's sourceProperty = target's targetProperty * multiplier + offset (optionally clamped). sourceProperty ∈ x|y|rotation|opacity|scale|strokeWidth|fillColor|strokeColor; " +
3546
+ "targetProperty ∈ x|y|rotation|opacity|scale. For fillColor/strokeColor the (clamped 0..1) driven value interpolates colorFrom→colorTo — so a relation drives COLOR. NEEDS a target (the property source). params: sourceProperty, targetProperty, multiplier, offset, clamp {min,max}, colorFrom, colorTo, signal. e.g. tint a dot by a planet's x: sourceProperty 'fillColor', targetProperty 'x', colorFrom '#0055ff', colorTo '#ff3300', signal true.\n- time_expression: self-relation (targetId=null); the source's params.property (x, y, rotation, opacity or scale) is driven by a formula of t (scene time) and v (params.baseValue). params: property (default 'y'), expression (e.g. 'sin(t*2)*50 + v'), baseVa" +
3547
+ "lue, signal (true also evaluates it as a pure f(t) via the Expression IR). The formula grammar: " +
3484
3548
  String(expressionGrammarText()) +
3485
3549
  ". A name outside it, or a wrong argument count, is refused by name: it would parse to nothing and the item would not move.\n\nEVENT-DRIVEN SCENE CHAINS (S11) — build a LONG scene as a graph of timed events, not a keyframe track. First make events with pinepaper_event (action:'create' → eventId). The relations below have an EVENT as their sourceId; firing the event runs the reaction. Chain beats over animation time with on_event_fire_after (timeline:'canvas'), have beats mutate the scene with on_event_add_relation, then kick it off with pinepaper_event action:'pulse'. Because canvas-timed fires are on the animation clock, the whole chain is scrub-, loop-, and replay-stable.\n- on_event_fire" +
3486
3550
  "_after: sourceId=event, targetId=the NEXT event. When source fires, pulse target after params.delay ms. params.timeline: 'canvas' (animation clock — use for scene chains) | 'wall' (real setTimeout). This is the chaining primitive.\n- on_event_fire_if: sourceId=event, targetId=any item (it is not fired). params.event = the event to pulse when params.predicate holds, params.else = the one when it does not. A branch is two relations: A —on_event_fire_if { predicate, event: B }→ any item; B —on_event_set_color→ lamp. Example predicate, \"score is at least 3\": { op:'cmp', cmp:'gte', a:{ op:'graph', read:'data', item:'item_3', property:'score' }, b:{ op:'const', value:3 } }.\n- on_event_add_rel" +
@@ -3572,8 +3636,6 @@ String(expressionGrammarText()) +
3572
3636
 
3573
3637
 
3574
3638
 
3575
-
3576
-
3577
3639
 
3578
3640
 
3579
3641
 
@@ -3591,7 +3653,7 @@ String(expressionGrammarText()) +
3591
3653
  },
3592
3654
  relationType: {
3593
3655
  type: 'string',
3594
- enum: ['orbits', 'follows', 'attached_to', 'maintains_distance', 'points_at', 'mirrors', 'parallax', 'bounds_to', 'anchored_in_world', 'spring_follow', 'repels', 'attracts', 'wiggle', 'animates', 'grows_from', 'staggered_with', 'indicates', 'circumscribes', 'wave_through', 'morphs_to', 'group_morphs_to', 'moves_along_path', 'construction_reveal', 'triggers_animation', 'syncs_with', 'tours', 'synced_to_audio', 'localizes_sound', 'expresses', 'camera_follows', 'camera_animates', 'is_midpoint_of', 'lies_on_line', 'is_centroid_of', 'is_circumcenter_of', 'concentric_with', 'driven_by', 'time_expression', 'on_click_fire', 'on_pointer_enter_fire', 'on_pointer_exit_fire', 'on_key_fire', 'on_event_fire_after', 'on_event_fire_if', 'on_event_add_relation', 'on_event_remove_relation', 'on_event_set_color', 'on_event_set_property', 'on_event_set_property_from_template', 'on_event_set_visibility', 'on_event_set_active', 'on_event_step_active', 'on_event_set_data', 'on_event_increment', 'on_event_toggle', 'on_event_store_set', 'on_event_store_increment', 'on_event_play_sound', 'looks_at', 'reacts_to', 'drag_to_turn', 'on_enter_set_color', 'on_enter_set_property', 'on_enter_set_property_from_template', 'on_enter_set_visibility', 'on_enter_set_data', 'on_enter_increment', 'on_enter_toggle', 'on_exit_set_color', 'on_exit_set_property', 'on_exit_set_property_from_template', 'on_exit_set_visibility', 'on_exit_set_data', 'on_exit_increment', 'on_exit_toggle', 'exclusive_group', 'menubar_group', 'restores_from', 'on_top_of', 'below', 'beside', 'inside', 'centered_on', 'aligned_with', 'part_of', 'connects_to', 'attached_to_tail', 'head_points_to'],
3656
+ enum: [...RelationTypeSchema.options],
3595
3657
  description: 'Type of relationship. A name registered with pinepaper_register_custom_relation in this session is accepted too.',
3596
3658
  },
3597
3659
  params: {
@@ -3634,7 +3696,7 @@ USE WHEN:
3634
3696
  targetId: { type: 'string', description: 'Target item ID' },
3635
3697
  relationType: {
3636
3698
  type: 'string',
3637
- enum: ['orbits', 'follows', 'attached_to', 'maintains_distance', 'points_at', 'mirrors', 'parallax', 'bounds_to', 'anchored_in_world', 'spring_follow', 'repels', 'attracts', 'wiggle', 'animates', 'grows_from', 'staggered_with', 'indicates', 'circumscribes', 'wave_through', 'morphs_to', 'group_morphs_to', 'moves_along_path', 'construction_reveal', 'triggers_animation', 'syncs_with', 'tours', 'synced_to_audio', 'localizes_sound', 'expresses', 'camera_follows', 'camera_animates', 'is_midpoint_of', 'lies_on_line', 'is_centroid_of', 'is_circumcenter_of', 'concentric_with', 'driven_by', 'time_expression', 'on_click_fire', 'on_pointer_enter_fire', 'on_pointer_exit_fire', 'on_key_fire', 'on_event_fire_after', 'on_event_fire_if', 'on_event_add_relation', 'on_event_remove_relation', 'on_event_set_color', 'on_event_set_property', 'on_event_set_property_from_template', 'on_event_set_visibility', 'on_event_set_active', 'on_event_step_active', 'on_event_set_data', 'on_event_increment', 'on_event_toggle', 'on_event_store_set', 'on_event_store_increment', 'on_event_play_sound', 'looks_at', 'reacts_to', 'drag_to_turn', 'on_enter_set_color', 'on_enter_set_property', 'on_enter_set_property_from_template', 'on_enter_set_visibility', 'on_enter_set_data', 'on_enter_increment', 'on_enter_toggle', 'on_exit_set_color', 'on_exit_set_property', 'on_exit_set_property_from_template', 'on_exit_set_visibility', 'on_exit_set_data', 'on_exit_increment', 'on_exit_toggle', 'exclusive_group', 'menubar_group', 'restores_from', 'on_top_of', 'below', 'beside', 'inside', 'centered_on', 'aligned_with', 'part_of', 'connects_to', 'attached_to_tail', 'head_points_to'],
3699
+ enum: [...RelationTypeSchema.options],
3638
3700
  description: 'Specific relation type to remove (optional - removes all if not specified)',
3639
3701
  },
3640
3702
  },
@@ -3662,7 +3724,7 @@ USE WHEN:
3662
3724
  itemId: { type: 'string', description: 'Item to query relations for' },
3663
3725
  relationType: {
3664
3726
  type: 'string',
3665
- enum: ['orbits', 'follows', 'attached_to', 'maintains_distance', 'points_at', 'mirrors', 'parallax', 'bounds_to', 'anchored_in_world', 'spring_follow', 'repels', 'attracts', 'wiggle', 'animates', 'grows_from', 'staggered_with', 'indicates', 'circumscribes', 'wave_through', 'morphs_to', 'group_morphs_to', 'moves_along_path', 'construction_reveal', 'triggers_animation', 'syncs_with', 'tours', 'synced_to_audio', 'localizes_sound', 'expresses', 'camera_follows', 'camera_animates', 'is_midpoint_of', 'lies_on_line', 'is_centroid_of', 'is_circumcenter_of', 'concentric_with', 'driven_by', 'time_expression', 'on_click_fire', 'on_pointer_enter_fire', 'on_pointer_exit_fire', 'on_key_fire', 'on_event_fire_after', 'on_event_fire_if', 'on_event_add_relation', 'on_event_remove_relation', 'on_event_set_color', 'on_event_set_property', 'on_event_set_property_from_template', 'on_event_set_visibility', 'on_event_set_active', 'on_event_step_active', 'on_event_set_data', 'on_event_increment', 'on_event_toggle', 'on_event_store_set', 'on_event_store_increment', 'on_event_play_sound', 'looks_at', 'reacts_to', 'drag_to_turn', 'on_enter_set_color', 'on_enter_set_property', 'on_enter_set_property_from_template', 'on_enter_set_visibility', 'on_enter_set_data', 'on_enter_increment', 'on_enter_toggle', 'on_exit_set_color', 'on_exit_set_property', 'on_exit_set_property_from_template', 'on_exit_set_visibility', 'on_exit_set_data', 'on_exit_increment', 'on_exit_toggle', 'exclusive_group', 'menubar_group', 'restores_from', 'on_top_of', 'below', 'beside', 'inside', 'centered_on', 'aligned_with', 'part_of', 'connects_to', 'attached_to_tail', 'head_points_to'],
3727
+ enum: [...RelationTypeSchema.options],
3666
3728
  description: 'Filter by relation type (optional)',
3667
3729
  },
3668
3730
  direction: {
@@ -3903,9 +3965,9 @@ USE WHEN:
3903
3965
  "survives into the design graph, save/reload and widget export, which keyframes generated from a formula do not describe.\n- PER-ITEM VARIATION across many items → staggered_with or wave_through express the whole group in ONE call. That collapses the 63-fold repetition a per-item time_expression still pays for.\n- GENUINELY PROCEDURAL scenes → pinepaper_execute_custom_code with app.create + app.addAnimation (which attaches keyframe tracks programmatically, so the keyframes are generated rather than typed), at 4k characters for the same scene — cheapest by an order of magnitude, because a loop collapses the repetition entirely. The trade is real and worth making deliberately: raw JS leaves N" +
3904
3966
  "O behavioral record — no graph edge, nothing SMIL or widget export can lift, nothing the ontology can read. Scene building fits the governor's 4s synchronous deadline easily (it is the injected loop guards, not the 10s async-tail budget, that bites a build loop).\n\n- TEXT THAT CHANGES OVER TIME -> 'content' IS keyframeable, and it is DISCRETE: it holds the current word until the next keyframe and does not tween. One thing worth knowing before you build a cascade out of it. A cascade of N words is N keyframes you have to write out, which is the reason to prefer the call below rather than any limitation. For the common case prefer pinepaper_execute_custom_code with app.textSequence(itemOrId" +
3905
3967
  ", words, { interval }): ONE item cycling its words in one call, against one item per word with opacity tracks faking the cut. It returns { ok: false, reason } on an id it cannot resolve.\n\nKeyframes are for motion that is AUTHORED, not computed. A track of hundreds of entries produced from a formula is the signal that one of the three above is the right route.\n\nANIMATABLE PROPERTIES:\n- position: [x, y] array (a { x, y } object is also accepted and normalized)\n- x, y: Individual coordinates\n- scale: Uniform scale\n- scaleX, scaleY: Axis scaling\n- rotation: Degrees\n- opacity: 0-1\n- fillColor: Color string\n- strokeColor: Color string\n- strokeWidth, width, height, radius, shadowBlur" +
3906
- ", shadowColor: Numbers / colours\n- fontSize: Number\n- skewX, skewY (current studio): degrees, clamped to ±89; they combine with rotation and scale. An older studio stores them without drawing and the result names them in ignoredProperties; there, a shear is a transform matrix track.\n- TEXT (current studio): fontStyle ('normal' / 'italic' / 'oblique', discrete — switches at the next key), textAlign ('left' / 'center' / 'right', discrete; the anchor stays put), lineHeight (px, or a multiple of fontSize up to 5), letterSpacing (px, or a CSS length such as '0.1em'). An older studio stores these keys without drawing them; the result names any it did not animate (ignoredProperties). fontWeigh" +
3907
- "t keys STEP: a font with fixed weights switches from one weight to the next once, partway through the move, and does not ramp. Key fontSize or scale for a smooth change.\n- STROKE DRAW-ON (paths): trimStart, trimEnd (0-1 of the path's length), trimOffset (shifts the visible window along it), dashArray. A draw-on is trimEnd 0 -> 1.\nThe authoritative list for a given item is pinepaper_get_animatable_properties — ask it rather than guessing.\n\nEASING — BY DEFAULT IT SHAPES THE SEGMENT ARRIVING AT THE KEYFRAME IT IS ON.\nThe move from keyframe A to keyframe B is eased by B's easing, not A's. So by default the FIRST keyframe's easing has no effect (no segment arrives at it) — a Ken Burns with" +
3908
- " easing only on its first key plays linear. Put the easing on the key you are moving TO, or pass easingAt: 'departure' for the other reading: each key's easing shapes the move LEAVING it, so the first key's easing eases the first move (current studio; an older one reads arrival and the result says so). 'hold' / 'step' stay on the key they mark either way.\n" +
3968
+ ", shadowColor: Numbers / colours\n- fontSize: Number\n- skewX, skewY (current studio): degrees, clamped to ±89; they combine with rotation and scale. An older studio stores them without drawing and the result names them in ignoredProperties; there, a shear is a transform matrix track.\n- TEXT (current studio): fontStyle ('normal' / 'italic' / 'oblique', discrete — switches at the next key), textAlign ('left' / 'center' / 'right', discrete; the anchor stays put), lineHeight (px, or a multiple of fontSize up to 5), letterSpacing (px, or a CSS length such as '0.1em'), wordSpacing (px or a CSS length), textTransform and textDecoration (discrete). An older studio stores these keys without drawi" +
3969
+ "ng them; the result names any it did not animate (ignoredProperties). fontWeight keys STEP: a font with fixed weights switches from one weight to the next once, partway through the move, and does not ramp. Key fontSize or scale for a smooth change.\n- STROKE DRAW-ON (paths): trimStart, trimEnd (0-1 of the path's length), trimOffset (shifts the visible window along it), dashArray. A draw-on is trimEnd 0 -> 1.\nThe authoritative list for a given item is pinepaper_get_animatable_properties — ask it rather than guessing.\n\nEASING — BY DEFAULT IT SHAPES THE SEGMENT ARRIVING AT THE KEYFRAME IT IS ON.\nThe move from keyframe A to keyframe B is eased by B's easing, not A's. So by default the FIRS" +
3970
+ "T keyframe's easing has no effect (no segment arrives at it) — a Ken Burns with easing only on its first key plays linear. Put the easing on the key you are moving TO, or pass easingAt: 'departure' for the other reading: each key's easing shapes the move LEAVING it, so the first key's easing eases the first move (current studio; an older one reads arrival and the result says so). 'hold' / 'step' stay on the key they mark either way.\n" +
3909
3971
  String(EASING_LINES) +
3910
3972
  "\n- or a custom cubic-bezier [x1, y1, x2, y2] — x in 0..1, y may exceed 0..1 for overshoot. keyframe_animate and camera_animate only.\nCOUNTERS: a text item with a counter (create_item / modify_item counter) also animates the numeric property counterValue, drawn through the counter's format — keyframe it to shape a count by hand (hold on a number, then race). Its keys override the counter's own from / to.\n\nHOLD KEYS (where the studio has them): interpolation: 'hold' on a key holds ITS value until the next key, then cuts — no in-between. Easing 'hold' / 'step' on the ARRIVING key does the same. A studio without hold keys refuses rather than interpolate; there, two keys a frame apart make " +
3911
3973
  "the cut. For a whole layer 'on twos', set stepTiming on the item (create_item / modify_item). A DISCRETE change (a bar turning red at 2 s, a label switching colour) needs this too: without hold / step every key ramps from the one before it, so the colour fades across the whole gap instead of switching. This applies to batch keyframe ops the same way.\n\nEvery easing renders exactly in video, GIF and APNG. Lottie and CSS get the closest single Bézier: springPlayful loses its wobble, and the in-out quart/quint/expo/circ curves are within 2–4 %. SMIL is exact for springs and those four.\n\nCLIP WINDOW (optional — Premiere/Canva-style clip behavior):\n- timeOffset: Shift the whole clip in canv" +
@@ -4611,7 +4673,7 @@ Needs a studio that publishes the item-side table; without one, the tool says so
4611
4673
  target: { type: 'string', description: 'Name of target item' },
4612
4674
  type: {
4613
4675
  type: 'string',
4614
- enum: ['orbits', 'follows', 'attached_to', 'maintains_distance', 'points_at', 'mirrors', 'parallax', 'bounds_to', 'anchored_in_world', 'spring_follow', 'repels', 'attracts', 'wiggle', 'animates', 'grows_from', 'staggered_with', 'indicates', 'circumscribes', 'wave_through', 'morphs_to', 'group_morphs_to', 'moves_along_path', 'construction_reveal', 'triggers_animation', 'syncs_with', 'tours', 'synced_to_audio', 'localizes_sound', 'expresses', 'camera_follows', 'camera_animates', 'is_midpoint_of', 'lies_on_line', 'is_centroid_of', 'is_circumcenter_of', 'concentric_with', 'driven_by', 'time_expression', 'on_click_fire', 'on_pointer_enter_fire', 'on_pointer_exit_fire', 'on_key_fire', 'on_event_fire_after', 'on_event_fire_if', 'on_event_add_relation', 'on_event_remove_relation', 'on_event_set_color', 'on_event_set_property', 'on_event_set_property_from_template', 'on_event_set_visibility', 'on_event_set_active', 'on_event_step_active', 'on_event_set_data', 'on_event_increment', 'on_event_toggle', 'on_event_store_set', 'on_event_store_increment', 'on_event_play_sound', 'looks_at', 'reacts_to', 'drag_to_turn', 'on_enter_set_color', 'on_enter_set_property', 'on_enter_set_property_from_template', 'on_enter_set_visibility', 'on_enter_set_data', 'on_enter_increment', 'on_enter_toggle', 'on_exit_set_color', 'on_exit_set_property', 'on_exit_set_property_from_template', 'on_exit_set_visibility', 'on_exit_set_data', 'on_exit_increment', 'on_exit_toggle', 'exclusive_group', 'menubar_group', 'restores_from', 'on_top_of', 'below', 'beside', 'inside', 'centered_on', 'aligned_with', 'part_of', 'connects_to', 'attached_to_tail', 'head_points_to'],
4676
+ enum: [...RelationTypeSchema.options],
4615
4677
  },
4616
4678
  params: {
4617
4679
  type: 'object',
@@ -8742,10 +8804,10 @@ EXAMPLES:
8742
8804
  "S may fail).\n\nONTOLOGY PREFLIGHT: Before code generation, this tool validates itemType, relationType, effectType, and generatorName against the canonical PinePaper vocabulary. A typo (e.g. \"circel\") returns a structured VALIDATION_ERROR with the operation index, the offending field, the canonical valid list, and a suggested fix — fix and retry rather than guessing. Pass skipValidation: true to bypass when intentionally using experimental vocabulary.\n\nWORKFLOW:\n1. pinepaper_agent_start_job → 2. the craft tools that fit the brief (styled_scene, story, character, text_style, compose…) → 3. pinepaper_agent_batch_execute (the primitive glue, in one call) → 4. pinepaper_agent_end_job (scr" +
8743
8805
  "eenshot for validation)\nAfter validation: user reviews screenshot → feedback → modify/recreate as needed.\n\nOPERATION TYPES (" +
8744
8806
  String(AgentBatchOperationTypeSchema.options.length) +
8745
- ") — use in this order:\n\nCANVAS SETUP:\n set_canvas_size — {width, height} or {preset: \"instagram-post\"|\"instagram-story\"|\"youtube-thumbnail\"|\"tiktok\"|\"full-hd-1080p\"|...} (studio preset keys, not platform names; an unknown one is refused with the list)\n set_background — {backgroundColor: \"#hex\"}\n execute_generator — {generatorName, generatorParams} → fills canvas with procedural art (paints WITHOUT fully registering items — see get_items; custom generator names need skipValidation:true)\n Generators: drawBokeh, drawGradientMesh, drawWaves, drawSunburst, drawSunsetScene, drawGrid, drawCircuit, drawPattern, drawStackedCircles, drawGeometricAbstract, drawWindField, drawF" +
8746
- "luidFlow, drawOrganicFlow, drawNoiseTexture, drawGlobeWireframe\n PineMath: drawFunctionPlot (y=f(x) plots), drawParametricCurve (x(t),y(t)), drawSimulation (pendulum, Lorenz, spring-mass), drawSpectrumAnalyzer (FFT), draw3DSurface (torus, sphere, Klein bottle)\n\nITEMS:\n create — {itemType, position: {x,y} or [x,y], properties}\n itemType: text, circle, rectangle, star, triangle, polygon, ellipse, path, line, arc, pentagon, hexagon, diamond, arrow, heart\n Properties per type:\n text: {content, fontSize, fontFamily, color, fontWeight}\n circle: {radius, color, strokeColor, strokeWidth}\n rectangle: {width, height, color, cornerRadius}\n star: {radius1, radiu" +
8747
- "s2, points, color}\n ellipse: {radiusX, radiusY, color}\n triangle/polygon: {width/sides, height/radius, color}\n All: opacity, shadowColor, shadowBlur, blendMode, strokeColor, strokeWidth\n modify — {itemId, properties} — instant reposition/restyle. To MOVE an item, set {x,y} here (a one-shot position change, not an animation).\n delete — {itemId}\n group — {itemIds: [\"$0\",\"$1\",...], groupName} — combine the parts of a composite object into ONE draggable/animatable entity. ALWAYS group a specific named object (car, robot, house, character) after creating its parts. Returns {groupId}, and the group takes the next \"$N\" like a create; safe — user can ungroup. Do NOT gro" +
8748
- "up a whole scene whose pieces move independently.\n\nANIMATION:\n animate — Loop animation: {itemId, animationType, animationOptions}\n animationType: " +
8807
+ ") — use in this order:\nEach op also takes its standalone tool's names: params (relation, apply_effect, execute_generator), preset / options (apply_mask), region (execute_generator), intensity / speed / delay / waveform / origin (animate, at the top or in animationOptions), and a modify op's changes flat beside itemId. The reply lists what it translated in translatedArguments.\n\nCANVAS SETUP:\n set_canvas_size — {width, height} or {preset: \"instagram-post\"|\"instagram-story\"|\"youtube-thumbnail\"|\"tiktok\"|\"full-hd-1080p\"|...} (studio preset keys, not platform names; an unknown one is refused with the list)\n set_background — {backgroundColor: \"#hex\"}\n execute_generator — {gen" +
8808
+ "eratorName, generatorParams} → fills canvas with procedural art (paints WITHOUT fully registering items — see get_items; custom generator names need skipValidation:true)\n Generators: drawBokeh, drawGradientMesh, drawWaves, drawSunburst, drawSunsetScene, drawGrid, drawCircuit, drawPattern, drawStackedCircles, drawGeometricAbstract, drawWindField, drawFluidFlow, drawOrganicFlow, drawNoiseTexture, drawGlobeWireframe\n PineMath: drawFunctionPlot (y=f(x) plots), drawParametricCurve (x(t),y(t)), drawSimulation (pendulum, Lorenz, spring-mass), drawSpectrumAnalyzer (FFT), draw3DSurface (torus, sphere, Klein bottle)\n\nITEMS:\n create — {itemType, position: {x,y} or [x,y], properties}\n " +
8809
+ "itemType: text, circle, rectangle, star, triangle, polygon, ellipse, path, line, arc, pentagon, hexagon, diamond, arrow, heart\n Properties per type:\n text: {content, fontSize, fontFamily, color, fontWeight}\n circle: {radius, color, strokeColor, strokeWidth}\n rectangle: {width, height, color, cornerRadius}\n star: {radius1, radius2, points, color}\n ellipse: {radiusX, radiusY, color}\n triangle/polygon: {width/sides, height/radius, color}\n All: opacity, shadowColor, shadowBlur, blendMode, strokeColor, strokeWidth\n modify — {itemId, properties} — instant reposition/restyle. To MOVE an item, set {x,y} here (a one-shot position change, not an animatio" +
8810
+ "n).\n delete — {itemId}\n group — {itemIds: [\"$0\",\"$1\",...], groupName} — combine the parts of a composite object into ONE draggable/animatable entity. ALWAYS group a specific named object (car, robot, house, character) after creating its parts. Returns {groupId}, and the group takes the next \"$N\" like a create; safe — user can ungroup. Do NOT group a whole scene whose pieces move independently.\n\nANIMATION:\n animate — Loop animation: {itemId, animationType, animationOptions}\n animationType: " +
8749
8811
  String(SIMPLE_ANIMATION_TYPES_LIST) +
8750
8812
  "\n keyframe_animate — Timed animation: {itemId, keyframes: [{time, properties, easing}], duration, loop, append}. Replaces the item's track unless append: true (or mode: 'merge', the studio's spelling); two ops on one item in a batch are merged into one.\n Animatable properties: opacity, scale, scaleX, scaleY, x, y, rotation, fillColor, strokeColor, strokeWidth, width, height, radius, fontSize, trimStart/trimEnd/trimOffset (path draw-on)\n Easing (on the keyframe being moved TO): " +
8751
8813
  String(KEYFRAME_EASINGS_LIST) +
@@ -8816,6 +8878,7 @@ String(EFFECT_TYPES_LIST) +
8816
8878
 
8817
8879
 
8818
8880
 
8881
+
8819
8882
 
8820
8883
 
8821
8884
  ),
@@ -9093,7 +9156,7 @@ String(AgentExportFormatSchema.options.join(', ')) +
9093
9156
  loop: { anyOf: [{ type: 'boolean' }, { type: 'integer', minimum: 0, maximum: 1000 }], description: 'gif / apng: true = loop forever, false / 0 / 1 = play once, n = play n times (email clients often want a few plays, not forever). Refused on video formats — see SEAMLESS LOOPS.' },
9094
9157
  broadcast: { type: 'boolean', description: 'mp4 only: broadcast-safe — BT.709, limited range (16-235), tagged bt709, constant bitrate with an 8 Mbps floor at 720p+ (4 below). result.video reports what the encoder did.' },
9095
9158
  frames: { type: 'integer', minimum: 1, maximum: 36000, description: 'Animated formats: exactly this many frames instead of duration. A duration makes ceil(duration x fps) frames (7.91667 s at 24 fps is 191, not 190), so use frames for frame-exact segments.' },
9096
- deterministic: { type: 'boolean', description: 'mp4 / webm: pins the container timestamps. WebM then comes out byte-identical; an MP4\'s encoded bytes can still differ run to run (the H.264 encoder is not bit-exact), so compare MP4s by the receipt\'s frameDigest, which is the same for the same frames, not by sha256.' },
9159
+ deterministic: { type: 'boolean', description: 'mp4 / webm: identical encoder input for the same scene, seed and settings (the receipt\'s frameHashes / frameDigest), and a container identical apart from the encoded samples. WebM is byte-identical. An MP4 is byte-identical only when result.video reports bitExact: true (same machine and browser build); a software H.264 MP4 at 640x480 and above is not, and warns determinism_not_pinned. Compare MP4s by frameDigest, not sha256.' },
9097
9160
  motionBlur: {
9098
9161
  type: 'object',
9099
9162
  description: 'mp4 / webm: real motion blur. Each frame is rendered `subframes` times across the shutter and averaged, so fast movement smears instead of strobing. Costs subframes× the render time: run estimateOnly first (it reports renders and renderCost). The result carries motionBlur (what the exporter applied).',
@@ -9106,6 +9169,7 @@ String(AgentExportFormatSchema.options.join(', ')) +
9106
9169
  bitrate: { type: 'integer', minimum: 100000, maximum: 200000000, description: 'mp4 / webm: target bits/s, replacing the quality-derived one. The browser encoder treats it as a CEILING; result.video.achievedBitrate is what it produced.' },
9107
9170
  minBitrate: { type: 'integer', minimum: 100000, maximum: 200000000, description: 'mp4 / webm: a floor; a miss is a fidelity warning naming the re-encode a delivery spec needs.' },
9108
9171
  bitrateMode: { type: 'string', enum: ['constant', 'variable'], description: "mp4 / webm: 'constant' (the default with broadcast) or 'variable'." },
9172
+ audioCodec: { type: 'string', enum: ['any', 'aac'], description: "mp4 only: 'aac' refuses the export (code audio_codec_unavailable, no file) where the soundtrack could only be Opus — Chromium on Linux has no AAC encoder; macOS and Windows do. 'any' (default) muxes Opus there with the audio_codec_opus warning (whatsappSafe: false). Silent MP4s are AAC everywhere." },
9109
9173
  maxBytes: { type: 'integer', minimum: 1, description: 'gif only: size budget in bytes (email: 1000000). Over it, the GIF is re-encoded at a smaller frame size, at most twice; result.budget reports each attempt.' },
9110
9174
  time: { type: 'number', minimum: 0, description: 'Stills only (png / jpg / webp / svg / pdf): render at this time in seconds; the playhead is put back afterwards. Without it a still is whatever frame the playhead is on, so repeated runs can differ.' },
9111
9175
  pdf: {