nodality 1.1.14 → 1.2.0

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 (201) hide show
  1. package/bin/mcp-server.mjs +41 -5
  2. package/bin/nodality.js +61 -0
  3. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  4. package/dist/animator.esm.js.LICENSE.txt +1 -1
  5. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  6. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  7. package/dist/base.cjs.js.LICENSE.txt +1 -1
  8. package/dist/base.esm.js.LICENSE.txt +1 -1
  9. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  10. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  11. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  12. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  13. package/dist/bundle.umd.js +1 -1
  14. package/dist/bundle.umd.js.LICENSE.txt +60 -1
  15. package/dist/button.cjs.js.LICENSE.txt +1 -1
  16. package/dist/button.esm.js.LICENSE.txt +1 -1
  17. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  18. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  19. package/dist/center.cjs.js.LICENSE.txt +1 -1
  20. package/dist/center.esm.js.LICENSE.txt +1 -1
  21. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  22. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  23. package/dist/code.cjs.js.LICENSE.txt +1 -1
  24. package/dist/code.esm.js.LICENSE.txt +1 -1
  25. package/dist/container.cjs.js.LICENSE.txt +1 -1
  26. package/dist/container.esm.js.LICENSE.txt +1 -1
  27. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  28. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  29. package/dist/designer.cjs.js +1 -1
  30. package/dist/designer.cjs.js.LICENSE.txt +60 -1
  31. package/dist/designer.esm.js +1 -1
  32. package/dist/designer.esm.js.LICENSE.txt +60 -1
  33. package/dist/element-mapper.cjs.js +1 -1
  34. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  35. package/dist/element-mapper.esm.js +1 -1
  36. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  37. package/dist/finalresult.esm.js +1 -1
  38. package/dist/finalresult.esm.js.LICENSE.txt +60 -1
  39. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  40. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  41. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  42. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  43. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  44. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  45. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  46. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  47. package/dist/free.cjs.js.LICENSE.txt +1 -1
  48. package/dist/free.esm.js.LICENSE.txt +1 -1
  49. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  50. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  51. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  52. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  53. package/dist/image.cjs.js.LICENSE.txt +1 -1
  54. package/dist/image.esm.js.LICENSE.txt +1 -1
  55. package/dist/index.cjs.js +1 -1
  56. package/dist/index.cjs.js.LICENSE.txt +60 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.esm.js +1 -1
  59. package/dist/index.esm.js.LICENSE.txt +60 -1
  60. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  61. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  62. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  63. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  64. package/dist/link.cjs.js.LICENSE.txt +1 -1
  65. package/dist/link.esm.js.LICENSE.txt +1 -1
  66. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  67. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  68. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  69. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  70. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  71. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  72. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  73. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  74. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  75. package/dist/picker.esm.js.LICENSE.txt +1 -1
  76. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  77. package/dist/progress.esm.js.LICENSE.txt +1 -1
  78. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  79. package/dist/radio.esm.js.LICENSE.txt +1 -1
  80. package/dist/range.cjs.js.LICENSE.txt +1 -1
  81. package/dist/range.esm.js.LICENSE.txt +1 -1
  82. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  83. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  84. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  85. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  86. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  87. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  88. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  89. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  90. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  91. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  92. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  93. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  94. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  95. package/dist/stack.esm.js.LICENSE.txt +1 -1
  96. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  97. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  98. package/dist/table.cjs.js.LICENSE.txt +1 -1
  99. package/dist/table.esm.js.LICENSE.txt +1 -1
  100. package/dist/text-field.cjs.js +1 -1
  101. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  102. package/dist/text-field.esm.js +1 -1
  103. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  104. package/dist/text.cjs.js.LICENSE.txt +1 -1
  105. package/dist/text.esm.js.LICENSE.txt +1 -1
  106. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  107. package/dist/theme.esm.js.LICENSE.txt +1 -1
  108. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  109. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  110. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  111. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  112. package/dist/video.cjs.js.LICENSE.txt +1 -1
  113. package/dist/video.esm.js.LICENSE.txt +1 -1
  114. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  115. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  116. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  117. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  118. package/layout/animator.js +1 -1
  119. package/layout/audio.js +1 -1
  120. package/layout/audionew.js +1 -1
  121. package/layout/base.js +1 -1
  122. package/layout/beta-desktop-bar.js +1 -1
  123. package/layout/beta-mobile-bar.js +1 -1
  124. package/layout/button.js +1 -1
  125. package/layout/center.js +1 -1
  126. package/layout/checkbox.js +1 -1
  127. package/layout/circle.js +1 -1
  128. package/layout/code.js +1 -1
  129. package/layout/container.js +1 -1
  130. package/layout/dropdown-2025.js +1 -1
  131. package/layout/flex-card.js +1 -1
  132. package/layout/flex-grid.js +1 -1
  133. package/layout/flex-row.js +1 -1
  134. package/layout/form-components/custom.js +1 -1
  135. package/layout/form-components/data-list.js +1 -1
  136. package/layout/form-components/floating-input.js +1 -1
  137. package/layout/form-components/form-all.js +1 -1
  138. package/layout/form-components/form.js +6 -1
  139. package/layout/form-components/image-picker.js +1 -1
  140. package/layout/form-components/picker.js +1 -1
  141. package/layout/form-components/radio.js +1 -1
  142. package/layout/form-components/radiogroup.js +1 -1
  143. package/layout/form-components/range.js +1 -1
  144. package/layout/free.js +1 -1
  145. package/layout/grid-switcher.js +1 -1
  146. package/layout/grid.js +1 -1
  147. package/layout/horizontal-scroller.js +1 -1
  148. package/layout/image.js +1 -1
  149. package/layout/index.js +1 -1
  150. package/layout/link.js +1 -1
  151. package/layout/list.js +1 -1
  152. package/layout/meta-adder.js +1 -1
  153. package/layout/modal-2025.js +1 -1
  154. package/layout/morph.js +1 -1
  155. package/layout/multiswitcher.js +1 -1
  156. package/layout/nav-bar.js +1 -1
  157. package/layout/nav-factor/custom-div.js +1 -1
  158. package/layout/new-nav-bar.js +1 -1
  159. package/layout/polygon.js +1 -1
  160. package/layout/prerender-site.js +1 -1
  161. package/layout/prerender.js +1 -1
  162. package/layout/progress.js +1 -1
  163. package/layout/row.js +1 -1
  164. package/layout/scroll-video.js +1 -1
  165. package/layout/side-bar.js +1 -1
  166. package/layout/side-nav-bar.js +1 -1
  167. package/layout/simple-bar.js +1 -1
  168. package/layout/slider-2025.js +1 -1
  169. package/layout/spacer.js +1 -1
  170. package/layout/stack.js +1 -1
  171. package/layout/svg.js +1 -1
  172. package/layout/switcher.js +1 -1
  173. package/layout/table.js +1 -1
  174. package/layout/text-field.js +13 -1
  175. package/layout/text.js +1 -1
  176. package/layout/ulist.js +1 -1
  177. package/layout/video.js +1 -1
  178. package/layout/wrap.js +1 -1
  179. package/layout/zoom-card.js +1 -1
  180. package/lib/agent-surface.js +347 -0
  181. package/lib/card-getter.js +1 -1
  182. package/lib/codegen.js +1 -1
  183. package/lib/data.js +1 -1
  184. package/lib/designer.js +22 -1
  185. package/lib/element-mapper.js +2 -1
  186. package/lib/keyframe-animation.js +1 -1
  187. package/lib/link-getter.js +1 -1
  188. package/lib/morph-node.js +47 -8
  189. package/lib/raster-inspect.js +1 -1
  190. package/lib/raster-ops.js +1 -1
  191. package/lib/raster-presets.js +1 -1
  192. package/lib/scroll-video.js +1 -1
  193. package/lib/seo.js +1 -1
  194. package/lib/stacker.js +1 -1
  195. package/lib/suggest.js +1 -1
  196. package/lib/theme.js +1 -1
  197. package/lib/transform-anim.js +1 -1
  198. package/lib/transition.js +1 -1
  199. package/lib/validate-nodes.js +196 -32
  200. package/lib/webmcp-adapter.js +307 -0
  201. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/morph-node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -123,6 +123,25 @@ const chainFor = (effect) => {
123
123
 
124
124
  const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
125
125
 
126
+ /**
127
+ * An id as written, reduced to the id itself.
128
+ *
129
+ * A node may name a state bare (`"home"`) or in selector form
130
+ * (`"#home"`). Both are accepted and both mean the same thing, because
131
+ * this is an element IDENTIFIER rather than a selector: the lookup is
132
+ * `getElementById`, plus a positional fallback against `E`, so `"#home"`
133
+ * would otherwise match nothing and the morph would fail with a console
134
+ * warning and no visible effect.
135
+ *
136
+ * Normalised HERE, once, rather than at the point of lookup: the states
137
+ * are keyed by this string, so a chain whose first edge writes `"#home"`
138
+ * and whose second writes `"home"` must arrive at ONE key. Stripping at
139
+ * lookup time would resolve both to the same element while keeping two
140
+ * entries in the map, and the second edge would then be unreachable.
141
+ */
142
+ const bareId = (id) =>
143
+ typeof id === "string" && id.startsWith("#") ? id.slice(1) : id;
144
+
126
145
  /**
127
146
  * A morph node, in one shape: a list of EDGES.
128
147
  *
@@ -170,19 +189,22 @@ const normalizeEdges = (m) => {
170
189
  // Labels are what the user is actually reading and clicking.
171
190
  const byLabel = e.to && !Array.isArray(e.to) && typeof e.to === "object"
172
191
  ? e.to : null;
192
+ // Deduplicated on the label side only. The array form is POSITIONAL
193
+ // — `toIds[triggers.indexOf(a)]` below — so collapsing a repeated
194
+ // id there would shift every later link onto the wrong destination.
173
195
  const toIds = byLabel
174
- ? [...new Set(Object.values(e.to))]
175
- : (Array.isArray(e.to) ? e.to : [e.to]);
196
+ ? [...new Set(Object.values(e.to))].map(bareId)
197
+ : (Array.isArray(e.to) ? e.to : [e.to]).map(bareId);
176
198
 
177
199
  const labelToId = new Map();
178
200
  if (byLabel) {
179
201
  for (const [label, id] of Object.entries(e.to)) {
180
- labelToId.set(norm(label), id);
202
+ labelToId.set(norm(label), bareId(id));
181
203
  }
182
204
  }
183
205
 
184
206
  return {
185
- from: e.from, byLabel, toIds, labelToId,
207
+ from: bareId(e.from), byLabel, toIds, labelToId,
186
208
  effect: opt("effect"),
187
209
  duration: opt("duration", 900),
188
210
  // Written as `!== false` rather than a truthy test: the
@@ -212,9 +234,13 @@ function applyMorphNodes(mount, elements, nodes) {
212
234
  // page. Position is what Des actually guarantees.
213
235
  const rendered = [...mount.children];
214
236
  const resolve = (id) => {
215
- const byId = document.getElementById(id);
237
+ // `bareId` again, and not only for symmetry: the descriptor in E
238
+ // may itself be written with a leading "#", so both sides are
239
+ // reduced before they are compared.
240
+ const want = bareId(id);
241
+ const byId = document.getElementById(want);
216
242
  if (byId && mount.contains(byId)) return byId;
217
- const i = (elements || []).findIndex((e) => e && e.id === id);
243
+ const i = (elements || []).findIndex((e) => e && bareId(e.id) === want);
218
244
  return i >= 0 ? rendered[i] : null;
219
245
  };
220
246
 
@@ -811,6 +837,13 @@ function setUpMorph(mount, m, resolve) {
811
837
  get active() { return active; },
812
838
  goTo, goToState,
813
839
  back() { if (tl) tl.to(0); },
840
+ // The control's own entry point, published for the agent surface.
841
+ // `back()` above reverses a RUNNING timeline and does nothing at
842
+ // rest; `goBack` is what the back button calls — queued, one
843
+ // press one level, reusing the live pipeline or rebuilding it.
844
+ // A derived go_back tool has to be the same action as the button
845
+ // or the two would unwind differently from the same state.
846
+ goBack,
814
847
  destroy() {
815
848
  if (tl) tl.destroy();
816
849
  for (const p of activeRasterPipelines()) {
@@ -821,4 +854,10 @@ function setUpMorph(mount, m, resolve) {
821
854
  };
822
855
  }
823
856
 
824
- export { applyMorphNodes };
857
+ // `normalizeEdges` is exported for DERIVATION, not for use: the agent
858
+ // surface reads the same edge list the controller runs, so the two can
859
+ // never describe different graphs. Reimplementing the normalisation
860
+ // there would have drifted the first time either side changed — which
861
+ // is precisely how the validator came to accept a `#id` the runtime
862
+ // refused to resolve.
863
+ export { applyMorphNodes, normalizeEdges };
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/seo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/stacker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/suggest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/theme.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/transition.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.14
2
+ * nodality v1.2.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -36,15 +36,36 @@ import { didYouMean, suggest } from "./suggest.js";
36
36
  * the runtime, which is why it has to be reported here.
37
37
  */
38
38
  const MORPH_FIELDS = [
39
- "op", "target", "from", "to",
39
+ "op", "target", "from", "to", "chain",
40
40
  "effect", "duration", "back", "live", "fade",
41
41
  ];
42
42
 
43
+ /**
44
+ * Every key an entry in `chain` reads. `from` and `to` make the edge; the
45
+ * rest are the node's own settings, overridable per edge — which is why
46
+ * this list is MORPH_FIELDS without `op`, `target` or `chain`, an edge
47
+ * being able to carry neither an op nor a nested chain of its own.
48
+ */
49
+ const EDGE_FIELDS = [
50
+ "from", "to", "effect", "duration", "back", "live", "fade",
51
+ ];
52
+
53
+ /**
54
+ * Every key an `agent-surface` node reads. The node is small on purpose:
55
+ * what it exposes is DERIVED, so there is nothing here to describe the
56
+ * tools with — only which parts of the page may be reached.
57
+ */
58
+ const AGENT_SURFACE_FIELDS = ["op", "name", "forms", "exclude"];
59
+
43
60
  /** Node families, told apart exactly the way the runtime tells them apart. */
44
61
  const familyOf = (node) => {
45
62
  if (!node || typeof node !== "object") return "invalid";
46
63
  const op = node.op;
47
- if (typeof op === "string") return op === "morph" ? "morph" : "raster";
64
+ if (typeof op === "string") {
65
+ if (op === "morph") return "morph";
66
+ if (op === "agent-surface") return "agent-surface";
67
+ return "raster";
68
+ }
48
69
  if (op && typeof op === "object" && typeof op.name === "string") return "design";
49
70
  return "invalid";
50
71
  };
@@ -96,8 +117,13 @@ export function validateNodes(nodes, elements) {
96
117
  push("BAD_TARGET", at, t, [], ["an element id"]);
97
118
  return;
98
119
  }
99
- // Ids are written bare in E and may be written with a leading
100
- // "#" in a node; accept both rather than inventing a rule.
120
+ // An id may be written bare ("home") or in selector form
121
+ // ("#home"). Both are accepted here because both are accepted
122
+ // by the runtime — `bareId` in morph-node.js reduces them to
123
+ // one key before anything is resolved. The two must agree: for
124
+ // a while this check tolerated "#home" while the runtime did
125
+ // not, so the validator passed a node that silently never
126
+ // morphed, which is the exact failure it exists to prevent.
101
127
  const bare = t.startsWith("#") ? t.slice(1) : t;
102
128
  if (idsKnown && !ids.includes(bare)) {
103
129
  push("UNKNOWN_TARGET", at, t, suggest(bare, ids), ids);
@@ -120,38 +146,176 @@ export function validateNodes(nodes, elements) {
120
146
 
121
147
  if (family === "design") return; // design nodes carry their own vocabulary
122
148
 
123
- if (family === "morph") {
124
- // The morph node's own contract. `from` and `to` are what make
125
- // it a morph; without them the node is inert.
126
- if (typeof node.from !== "string") {
127
- push("MISSING_FIELD", `${path}.from`, node.from, [],
128
- ["the id of the element that morphs"]);
129
- } else {
130
- checkTargets(node.from, `${path}.from`);
149
+ if (family === "agent-surface") {
150
+ // The ids this node names must exist, for the usual reason:
151
+ // a surface that silently exposes nothing is the agentic
152
+ // form of the silent no-op, and an agent has no way to see
153
+ // that the tool it expected was never registered.
154
+ const formIds = [];
155
+ const walkForms = (list) => {
156
+ if (!Array.isArray(list)) return;
157
+ for (const el of list) {
158
+ if (!el || typeof el !== "object") continue;
159
+ if (el.type === "form" && typeof el.id === "string") formIds.push(el.id);
160
+ walkForms(el.children);
161
+ }
162
+ };
163
+ if (idsKnown) walkForms(elements);
164
+
165
+ if (node.name !== undefined && typeof node.name !== "string") {
166
+ push("BAD_FIELD", `${path}.name`, node.name, [],
167
+ ["a string used to prefix every derived tool name"]);
131
168
  }
132
- if (node.to === undefined) {
133
- push("MISSING_FIELD", `${path}.to`, undefined, [],
134
- ["[\"id\", ...]", "{ \"Link label\": \"id\" }"]);
135
- } else if (Array.isArray(node.to)) {
136
- checkTargets(node.to, `${path}.to`);
137
- } else if (node.to && typeof node.to === "object") {
138
- for (const label in node.to) {
139
- checkTargets(node.to[label], `${path}.to.${label}`);
169
+
170
+ for (const key of ["forms", "exclude"]) {
171
+ if (node[key] === undefined) continue;
172
+ if (!Array.isArray(node[key])) {
173
+ push("BAD_FIELD", `${path}.${key}`, node[key], [], ["an array of ids"]);
174
+ continue;
140
175
  }
141
- } else {
142
- push("BAD_FIELD", `${path}.to`, node.to, [],
143
- ["an array of ids, or a map of link label to id"]);
176
+ node[key].forEach((v, k) => {
177
+ const at = `${path}.${key}[${k}]`;
178
+ if (typeof v !== "string") {
179
+ push("BAD_TARGET", at, v, [], ["an element id"]);
180
+ return;
181
+ }
182
+ const bare = v.startsWith("#") ? v.slice(1) : v;
183
+ if (key === "forms") {
184
+ // Checked against FORMS, not against every id: a
185
+ // node naming a heading would otherwise pass here
186
+ // and derive no tool at all.
187
+ if (idsKnown && !formIds.includes(bare)) {
188
+ push("UNKNOWN_FORM", at, v, suggest(bare, formIds), formIds);
189
+ }
190
+ } else if (idsKnown && !ids.includes(bare)) {
191
+ push("UNKNOWN_STATE", at, v, suggest(bare, ids), ids);
192
+ }
193
+ });
194
+ }
195
+
196
+ // A surface with no graph and no forms still derives
197
+ // `read_view`, which is legitimate — a page can be worth
198
+ // reading and nothing else. Reported so that a node written
199
+ // in the expectation of more says so.
200
+ const hasChain = nodes.some((n) => n && n.op === "morph");
201
+ const hasForms = Array.isArray(node.forms) && node.forms.length > 0;
202
+ if (!hasChain && !hasForms) {
203
+ push("EMPTY_SURFACE", path, node, [],
204
+ ["add a { op: \"morph\" } node, or name a form in `forms`"]);
205
+ }
206
+
207
+ for (const key in node) {
208
+ if (AGENT_SURFACE_FIELDS.includes(key)) continue;
209
+ push("UNKNOWN_PARAM", `${path}.${key}`, key,
210
+ suggest(key, AGENT_SURFACE_FIELDS), AGENT_SURFACE_FIELDS);
144
211
  }
145
- if (node.duration !== undefined &&
146
- (typeof node.duration !== "number" || !(node.duration > 0))) {
147
- push("BAD_VALUE", `${path}.duration`, node.duration, [],
148
- ["a positive number of milliseconds"]);
212
+ return;
213
+ }
214
+
215
+ if (family === "morph") {
216
+ // `from` and `to` are what make a hop; without them it is
217
+ // inert. Factored out because a chain edge has exactly the
218
+ // same two ends as a single-hop node, and the diagnostics
219
+ // should not differ by where the edge was written.
220
+ const checkEnds = (o, at) => {
221
+ if (typeof o.from !== "string") {
222
+ push("MISSING_FIELD", `${at}.from`, o.from, [],
223
+ ["the id of the element that morphs"]);
224
+ } else {
225
+ checkTargets(o.from, `${at}.from`);
226
+ }
227
+ if (o.to === undefined) {
228
+ push("MISSING_FIELD", `${at}.to`, undefined, [],
229
+ ["[\"id\", ...]", "{ \"Link label\": \"id\" }"]);
230
+ } else if (Array.isArray(o.to)) {
231
+ checkTargets(o.to, `${at}.to`);
232
+ } else if (o.to && typeof o.to === "object") {
233
+ for (const label in o.to) {
234
+ checkTargets(o.to[label], `${at}.to.${label}`);
235
+ }
236
+ } else {
237
+ push("BAD_FIELD", `${at}.to`, o.to, [],
238
+ ["an array of ids, or a map of link label to id"]);
239
+ }
240
+ };
241
+
242
+ // The settings, which are hoisted defaults on the node and
243
+ // overrides on an edge, so both are checked the same way.
244
+ // `back` and `live` are compared against `false`/`true` by
245
+ // identity in the runtime, which makes a STRING the dangerous
246
+ // mistake: `live: "true"` is not `=== true`, so the live
247
+ // backend silently stays off and the page still works, on the
248
+ // other backend, looking as though the flag took.
249
+ const checkOpts = (o, at) => {
250
+ if (o.duration !== undefined &&
251
+ (typeof o.duration !== "number" || !(o.duration > 0))) {
252
+ push("BAD_VALUE", `${at}.duration`, o.duration, [],
253
+ ["a positive number of milliseconds"]);
254
+ }
255
+ if (o.effect !== undefined &&
256
+ typeof o.effect !== "string" && !Array.isArray(o.effect)) {
257
+ push("BAD_FIELD", `${at}.effect`, o.effect, [],
258
+ ["a preset name, or an inline array of raster nodes"]);
259
+ }
260
+ for (const flag of ["back", "live"]) {
261
+ if (o[flag] !== undefined && typeof o[flag] !== "boolean") {
262
+ push("BAD_VALUE", `${at}.${flag}`, o[flag], [],
263
+ ["true", "false"]);
264
+ }
265
+ }
266
+ if (o.fade !== undefined && typeof o.fade !== "string") {
267
+ push("BAD_FIELD", `${at}.fade`, o.fade, [],
268
+ ["a fade mode, as a string"]);
269
+ }
270
+ };
271
+
272
+ // A node is either one hop or a chain, and the runtime decides
273
+ // by looking at `chain` FIRST: `normalizeEdges` takes a
274
+ // non-empty chain and never reads node-level `from`/`to`. The
275
+ // check mirrors that order, so what passes here is what runs.
276
+ const chained = Array.isArray(node.chain) && node.chain.length > 0;
277
+
278
+ if (node.chain !== undefined && !Array.isArray(node.chain)) {
279
+ push("BAD_FIELD", `${path}.chain`, node.chain, [],
280
+ ["an array of edges: [{ from, to }, ...]"]);
281
+ } else if (Array.isArray(node.chain) && !node.chain.length) {
282
+ // The runtime does not reject this — it falls back to the
283
+ // single-hop fields — but it is never what was meant.
284
+ push("BAD_VALUE", `${path}.chain`, node.chain, [],
285
+ ["at least one edge, or no `chain` key at all"]);
149
286
  }
150
- if (node.effect !== undefined &&
151
- typeof node.effect !== "string" && !Array.isArray(node.effect)) {
152
- push("BAD_FIELD", `${path}.effect`, node.effect, [],
153
- ["a preset name, or an inline array of raster nodes"]);
287
+
288
+ checkOpts(node, path);
289
+
290
+ if (chained) {
291
+ // `from`/`to` written BESIDE a chain are dead, not merged.
292
+ // Worth reporting precisely because the page still works,
293
+ // using the chain, so the ignored pair looks as though it
294
+ // took effect.
295
+ for (const key of ["from", "to"]) {
296
+ if (node[key] !== undefined) {
297
+ push("IGNORED_FIELD", `${path}.${key}`, node[key], [],
298
+ ["remove it, or drop `chain` and keep the single hop"]);
299
+ }
300
+ }
301
+ node.chain.forEach((edge, j) => {
302
+ const at = `${path}.chain[${j}]`;
303
+ if (!edge || typeof edge !== "object" || Array.isArray(edge)) {
304
+ push("BAD_FIELD", at, edge, [], ["{ from, to }"]);
305
+ return;
306
+ }
307
+ checkEnds(edge, at);
308
+ checkOpts(edge, at);
309
+ for (const key in edge) {
310
+ if (EDGE_FIELDS.includes(key)) continue;
311
+ push("UNKNOWN_PARAM", `${at}.${key}`, key,
312
+ suggest(key, EDGE_FIELDS), EDGE_FIELDS);
313
+ }
314
+ });
315
+ } else {
316
+ checkEnds(node, path);
154
317
  }
318
+
155
319
  // Unknown keys, checked here for the same reason they are
156
320
  // checked on a raster node: a morph silently ignores what it
157
321
  // does not recognise, so `duraton: 900` produces a transition