nodality 1.2.10 → 1.3.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 (283) hide show
  1. package/bin/mcp-server.mjs +131 -0
  2. package/bin/schema-cli.mjs +55 -11
  3. package/dist/animator.cjs.js +1 -1
  4. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  5. package/dist/animator.esm.js +1 -1
  6. package/dist/animator.esm.js.LICENSE.txt +1 -1
  7. package/dist/audionew.cjs.js +1 -1
  8. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  9. package/dist/audionew.esm.js +1 -1
  10. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  11. package/dist/base.cjs.js +1 -1
  12. package/dist/base.cjs.js.LICENSE.txt +1 -1
  13. package/dist/base.esm.js +1 -1
  14. package/dist/base.esm.js.LICENSE.txt +1 -1
  15. package/dist/beta-desktop-bar.cjs.js +1 -1
  16. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  17. package/dist/beta-desktop-bar.esm.js +1 -1
  18. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  19. package/dist/beta-mobile-bar.cjs.js +1 -1
  20. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  21. package/dist/beta-mobile-bar.esm.js +1 -1
  22. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  23. package/dist/bundle.umd.js +1 -1
  24. package/dist/bundle.umd.js.LICENSE.txt +1 -28
  25. package/dist/button.cjs.js +1 -1
  26. package/dist/button.cjs.js.LICENSE.txt +1 -1
  27. package/dist/button.esm.js +1 -1
  28. package/dist/button.esm.js.LICENSE.txt +1 -1
  29. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  30. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  31. package/dist/center.cjs.js +1 -1
  32. package/dist/center.cjs.js.LICENSE.txt +1 -1
  33. package/dist/center.esm.js +1 -1
  34. package/dist/center.esm.js.LICENSE.txt +1 -1
  35. package/dist/checkbox.cjs.js +1 -1
  36. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  37. package/dist/checkbox.esm.js +1 -1
  38. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  39. package/dist/code.cjs.js +1 -1
  40. package/dist/code.cjs.js.LICENSE.txt +1 -1
  41. package/dist/code.esm.js +1 -1
  42. package/dist/code.esm.js.LICENSE.txt +1 -1
  43. package/dist/container.cjs.js +1 -1
  44. package/dist/container.cjs.js.LICENSE.txt +1 -1
  45. package/dist/container.esm.js +1 -1
  46. package/dist/container.esm.js.LICENSE.txt +1 -1
  47. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  48. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  49. package/dist/designer.cjs.js +1 -1
  50. package/dist/designer.cjs.js.LICENSE.txt +1 -28
  51. package/dist/designer.esm.js +1 -1
  52. package/dist/designer.esm.js.LICENSE.txt +1 -28
  53. package/dist/element-mapper.cjs.js +1 -1
  54. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  55. package/dist/element-mapper.esm.js +1 -1
  56. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  57. package/dist/finalresult.esm.js +1 -1
  58. package/dist/finalresult.esm.js.LICENSE.txt +1 -28
  59. package/dist/flex-card.cjs.js +1 -1
  60. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  61. package/dist/flex-card.esm.js +1 -1
  62. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  63. package/dist/flex-grid.cjs.js +1 -1
  64. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  65. package/dist/flex-grid.esm.js +1 -1
  66. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  67. package/dist/flex-row.cjs.js +1 -1
  68. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  69. package/dist/flex-row.esm.js +1 -1
  70. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  71. package/dist/floating-input.cjs.js +1 -1
  72. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  73. package/dist/floating-input.esm.js +1 -1
  74. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  75. package/dist/free.cjs.js +1 -1
  76. package/dist/free.cjs.js.LICENSE.txt +1 -1
  77. package/dist/free.esm.js +1 -1
  78. package/dist/free.esm.js.LICENSE.txt +1 -1
  79. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  80. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  81. package/dist/image-picker.cjs.js +1 -1
  82. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  83. package/dist/image-picker.esm.js +1 -1
  84. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  85. package/dist/image.cjs.js +1 -1
  86. package/dist/image.cjs.js.LICENSE.txt +1 -1
  87. package/dist/image.esm.js +1 -1
  88. package/dist/image.esm.js.LICENSE.txt +1 -1
  89. package/dist/index.cjs.js +1 -1
  90. package/dist/index.cjs.js.LICENSE.txt +1 -28
  91. package/dist/index.d.ts +3 -2
  92. package/dist/index.esm.js +1 -1
  93. package/dist/index.esm.js.LICENSE.txt +1 -28
  94. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  95. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  96. package/dist/link-getter.cjs.js +1 -1
  97. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  98. package/dist/link-getter.esm.js +1 -1
  99. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  100. package/dist/link.cjs.js +1 -1
  101. package/dist/link.cjs.js.LICENSE.txt +1 -1
  102. package/dist/link.esm.js +1 -1
  103. package/dist/link.esm.js.LICENSE.txt +1 -1
  104. package/dist/meta-adder.cjs.js +1 -1
  105. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  106. package/dist/meta-adder.esm.js +1 -1
  107. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  108. package/dist/modal-2025.cjs.js +1 -1
  109. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  110. package/dist/modal-2025.esm.js +1 -1
  111. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  112. package/dist/multiswitcher.cjs.js +1 -1
  113. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  114. package/dist/multiswitcher.esm.js +1 -1
  115. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  116. package/dist/new-nav-bar.cjs.js +1 -1
  117. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  118. package/dist/new-nav-bar.esm.js +1 -1
  119. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  120. package/dist/picker.cjs.js +1 -1
  121. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  122. package/dist/picker.esm.js +1 -1
  123. package/dist/picker.esm.js.LICENSE.txt +1 -1
  124. package/dist/progress.cjs.js +1 -1
  125. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  126. package/dist/progress.esm.js +1 -1
  127. package/dist/progress.esm.js.LICENSE.txt +1 -1
  128. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  129. package/dist/radio.esm.js.LICENSE.txt +1 -1
  130. package/dist/range.cjs.js.LICENSE.txt +1 -1
  131. package/dist/range.esm.js.LICENSE.txt +1 -1
  132. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  133. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  134. package/dist/side-bar.cjs.js +1 -1
  135. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  136. package/dist/side-bar.esm.js +1 -1
  137. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  138. package/dist/side-nav-bar.cjs.js +1 -1
  139. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  140. package/dist/side-nav-bar.esm.js +1 -1
  141. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  142. package/dist/simple-bar.cjs.js +1 -1
  143. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  144. package/dist/simple-bar.esm.js +1 -1
  145. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  146. package/dist/slider-2025.cjs.js +1 -1
  147. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  148. package/dist/slider-2025.esm.js +1 -1
  149. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  150. package/dist/spacer.cjs.js +1 -1
  151. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  152. package/dist/spacer.esm.js +1 -1
  153. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  154. package/dist/stack.cjs.js +1 -1
  155. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  156. package/dist/stack.esm.js +1 -1
  157. package/dist/stack.esm.js.LICENSE.txt +1 -1
  158. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  159. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  160. package/dist/table.cjs.js +1 -1
  161. package/dist/table.cjs.js.LICENSE.txt +1 -1
  162. package/dist/table.esm.js +1 -1
  163. package/dist/table.esm.js.LICENSE.txt +1 -1
  164. package/dist/text-field.cjs.js +1 -1
  165. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  166. package/dist/text-field.esm.js +1 -1
  167. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  168. package/dist/text.cjs.js +1 -1
  169. package/dist/text.cjs.js.LICENSE.txt +1 -1
  170. package/dist/text.esm.js +1 -1
  171. package/dist/text.esm.js.LICENSE.txt +1 -1
  172. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  173. package/dist/theme.esm.js.LICENSE.txt +1 -1
  174. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  175. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  176. package/dist/ulist.cjs.js +1 -1
  177. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  178. package/dist/ulist.esm.js +1 -1
  179. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  180. package/dist/video.cjs.js +1 -1
  181. package/dist/video.cjs.js.LICENSE.txt +1 -1
  182. package/dist/video.esm.js +1 -1
  183. package/dist/video.esm.js.LICENSE.txt +1 -1
  184. package/dist/wrap.cjs.js +1 -1
  185. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  186. package/dist/wrap.esm.js +1 -1
  187. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  188. package/dist/zoom-card.cjs.js +1 -1
  189. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  190. package/dist/zoom-card.esm.js +1 -1
  191. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  192. package/layout/animator.js +15 -2
  193. package/layout/audio.js +1 -1
  194. package/layout/audionew.js +1 -1
  195. package/layout/base.js +1 -1
  196. package/layout/beta-desktop-bar.js +1 -1
  197. package/layout/beta-mobile-bar.js +1 -1
  198. package/layout/button.js +1 -1
  199. package/layout/center.js +1 -1
  200. package/layout/checkbox.js +1 -1
  201. package/layout/circle.js +1 -1
  202. package/layout/code.js +1 -1
  203. package/layout/container.js +1 -1
  204. package/layout/dropdown-2025.js +10 -3
  205. package/layout/flex-card.js +1 -1
  206. package/layout/flex-grid.js +1 -1
  207. package/layout/flex-row.js +1 -1
  208. package/layout/form-components/custom.js +1 -1
  209. package/layout/form-components/data-list.js +1 -1
  210. package/layout/form-components/floating-input.js +14 -1
  211. package/layout/form-components/form-all.js +1 -1
  212. package/layout/form-components/form.js +1 -1
  213. package/layout/form-components/image-picker.js +5 -3
  214. package/layout/form-components/picker.js +1 -1
  215. package/layout/form-components/radio.js +1 -1
  216. package/layout/form-components/radiogroup.js +1 -1
  217. package/layout/form-components/range.js +1 -1
  218. package/layout/free.js +1 -1
  219. package/layout/grid-switcher.js +1 -1
  220. package/layout/grid.js +1 -1
  221. package/layout/horizontal-scroller.js +1 -1
  222. package/layout/image.js +15 -2
  223. package/layout/index.js +1 -1
  224. package/layout/link.js +1 -1
  225. package/layout/list.js +1 -1
  226. package/layout/meta-adder.js +1 -1
  227. package/layout/modal-2025.js +1 -1
  228. package/layout/morph.js +1 -1
  229. package/layout/multiswitcher.js +1 -1
  230. package/layout/nav-bar.js +1 -1
  231. package/layout/nav-factor/custom-div.js +1 -1
  232. package/layout/new-nav-bar.js +1 -1
  233. package/layout/polygon.js +1 -1
  234. package/layout/prerender-site.js +1 -1
  235. package/layout/prerender.js +1 -1
  236. package/layout/progress.js +1 -1
  237. package/layout/row.js +1 -1
  238. package/layout/scroll-video.js +1 -1
  239. package/layout/side-bar.js +11 -3
  240. package/layout/side-nav-bar.js +16 -3
  241. package/layout/simple-bar.js +1 -1
  242. package/layout/slider-2025.js +1 -1
  243. package/layout/spacer.js +1 -1
  244. package/layout/stack.js +1 -1
  245. package/layout/svg.js +1 -1
  246. package/layout/switcher.js +1 -1
  247. package/layout/table.js +1 -1
  248. package/layout/text-field.js +16 -1
  249. package/layout/text.js +28 -1
  250. package/layout/ulist.js +1 -1
  251. package/layout/video.js +1 -1
  252. package/layout/wrap.js +1 -1
  253. package/layout/zoom-card.js +1 -1
  254. package/lib/agent-surface.js +1 -1
  255. package/lib/card-getter.js +1 -1
  256. package/lib/check-page.js +239 -0
  257. package/lib/codegen.js +1 -1
  258. package/lib/data.js +1 -1
  259. package/lib/designer.js +64 -2
  260. package/lib/element-mapper.js +113 -23
  261. package/lib/element-params.generated.js +2 -2
  262. package/lib/keyframe-animation.js +1 -1
  263. package/lib/link-getter.js +1 -1
  264. package/lib/morph-node.js +8 -1
  265. package/lib/normalize-spec.js +134 -0
  266. package/lib/parse-html.js +14 -2
  267. package/lib/raster-inspect.js +1 -1
  268. package/lib/raster-ops.js +1 -1
  269. package/lib/raster-presets.js +1 -1
  270. package/lib/resolve-refs.js +121 -0
  271. package/lib/scroll-video.js +1 -1
  272. package/lib/seo.js +1 -1
  273. package/lib/stacker.js +1 -1
  274. package/lib/suggest.js +1 -1
  275. package/lib/theme.js +1 -1
  276. package/lib/transform-anim.js +1 -1
  277. package/lib/transition.js +1 -1
  278. package/lib/validate-nodes.js +99 -3
  279. package/lib/webmcp-adapter.js +1 -1
  280. package/package.json +6 -2
  281. package/schema.json +8152 -0
  282. package/scripts/generate-schema.mjs +251 -0
  283. package/skills/nodality/SKILL.md +121 -1
package/lib/designer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -13,6 +13,8 @@ import { ElementMapper } from "../lib/element-mapper.js";
13
13
  import { applyMorphNodes } from "../lib/morph-node.js";
14
14
  import { installAgentSurface } from "../lib/webmcp-adapter.js";
15
15
  import { annotateRoundTrip } from "../lib/parse-html.js";
16
+ import { resolveRefs, collectRefs } from "../lib/resolve-refs.js";
17
+ import { normalizeSpec } from "../lib/normalize-spec.js";
16
18
  import { Animator } from "../layout/animator.js";
17
19
  import { Base } from "../layout/base.js";
18
20
  import { Text } from "../layout/text.js";
@@ -435,7 +437,33 @@ new Text("row.").set({
435
437
  // Kept for phase T: a morph node resolves `from`/`to` ids against
436
438
  // the rendered children BY POSITION, so it needs the descriptor
437
439
  // order — components do not all carry their id into the DOM.
438
- this._elements = Array.isArray(arr) ? arr : [];
440
+ // Tier 3: `{ $ref: "name" }` is expanded here, so everything
441
+ // downstream — the mapper, morph nodes resolving by position, the
442
+ // agent surface, annotation — sees ordinary elements and needs no
443
+ // knowledge of references at all. No defs, no change: `resolveRefs`
444
+ // returns its input untouched.
445
+ arr = resolveRefs(Array.isArray(arr) ? arr : [], this._defs);
446
+
447
+ // Tier 6: `src`/`href` mean `url`, `options` means `items`. Rewritten
448
+ // once, here, so the mapper and every component below it only ever see
449
+ // the canonical names and none of them needs to know an alias exists.
450
+ // Additive — a spec using the canonical names comes through unchanged.
451
+ arr = normalizeSpec(arr);
452
+
453
+ // A name with no definition is refused here, naming what is defined.
454
+ // Left alone it reaches the mapper as an element with no `type` and
455
+ // fails as `Unknown element type "undefined"`, which sends the reader
456
+ // looking for a typo in a type rather than a missing definition.
457
+ {
458
+ const dangling = collectRefs(arr).filter(
459
+ (r) => !this._defs || !Object.prototype.hasOwnProperty.call(this._defs, r.name));
460
+ if (dangling.length) {
461
+ const defined = Object.keys(this._defs || {});
462
+ throw new Error(
463
+ `[nodality] no definition for $ref "${dangling[0].name}" at ` +
464
+ `${dangling[0].path}. Defined: ${defined.length ? defined.join(", ") : "(none)"}`);
465
+ }
466
+ }
439
467
 
440
468
  this.css = [];
441
469
  this.elCSS = [];
@@ -466,6 +494,15 @@ customOptions.forEach(item => {
466
494
  ...arr.filter((e) => !e || (e.type !== "nav" && e.type !== "sideNav")),
467
495
  ];
468
496
 
497
+ // Recorded AFTER the lift, not before. Everything that consumes
498
+ // `_elements` matches it against rendered children BY POSITION —
499
+ // morph resolving `from`/`to`, and the round-trip writing each
500
+ // descriptor onto the node it produced. The array was being stored in
501
+ // the author's order while the page rendered in this one, so any spec
502
+ // with a nav after another element mapped descriptors to the wrong
503
+ // nodes. Found while wiring Tier 3, which needed the same array.
504
+ this._elements = arr;
505
+
469
506
  for (var i = 0; i < arr.length; i++) {
470
507
  let el = arr[i];
471
508
  let ela = null;
@@ -1339,6 +1376,31 @@ new Slider(texts, null, { tintColor: "#e74c3c", inactiveColor: "#ccc" })
1339
1376
  return this;
1340
1377
  }
1341
1378
 
1379
+ /**
1380
+ * Shared fragments, referenced from E as `{ $ref: "name" }`.
1381
+ *
1382
+ * Ordinary data: one object, imported by every page's entry, so the
1383
+ * sharing is a plain import rather than a new authoring mode. Worth it
1384
+ * from two pages up — at one page a reference costs more than inlining,
1385
+ * which is why nothing here is automatic.
1386
+ *
1387
+ * MUST be called before `add`. `add` renders as it goes, so definitions
1388
+ * arriving afterwards are simply too late — and the failure was
1389
+ * "Unknown element type undefined" from deep in the mapper, which says
1390
+ * nothing about the real mistake. The wrong order is now refused where it
1391
+ * happens, in the words of the thing the caller did.
1392
+ */
1393
+ defs(map) {
1394
+ if (this._elements !== undefined) {
1395
+ throw new Error(
1396
+ '[nodality] .defs() must come before .add(): add() renders as it ' +
1397
+ 'goes, so definitions given afterwards cannot be used. Chain it as ' +
1398
+ 'new Des().defs({ ... }).nodes(N).add(E)');
1399
+ }
1400
+ this._defs = (map && typeof map === "object" && !Array.isArray(map)) ? map : {};
1401
+ return this;
1402
+ }
1403
+
1342
1404
  set(obj) {
1343
1405
  // Emit-only mode: invoked from `nodality compile`. Capture the
1344
1406
  // imperative code array (the same content the `code: true` on-page
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -88,6 +88,17 @@ const ELEMENT_TYPES = [
88
88
  // ignoring it was the defect Stage 4 fixes - throwing on it would be a worse
89
89
  // one. Missing fields are filled rather than left undefined, because the
90
90
  // mapper derives an id from the title and a missing title threw.
91
+ // One id, however it was spelled. `#hero` and `hero` name the same element,
92
+ // and until now which one worked depended on the subsystem: raster targeting
93
+ // matched by exact string, morph-node stripped the hash, and chainReact
94
+ // required it. Both spellings are accepted everywhere now, and neither is made
95
+ // canonical — normalising only at the point of comparison means every existing
96
+ // page keeps working whichever it used.
97
+ const sameId = (a, b) => {
98
+ const bare = (v) => typeof v === "string" && v.charAt(0) === "#" ? v.slice(1) : v;
99
+ return bare(a) === bare(b);
100
+ };
101
+
91
102
  const navEntry = (i) =>
92
103
  (typeof i === "string" || typeof i === "number")
93
104
  ? { title: String(i), link: "#e" }
@@ -202,8 +213,12 @@ class ElementMapper { // 22:09:58 04/11/2024
202
213
  font: "Arial"
203
214
  },
204
215
  headStyle: {
216
+ // White on #ff6d22 measured 2.81:1 against a 4.5:1
217
+ // requirement — a header that reads as styled and is not
218
+ // legible. #c2410c is 5.18:1, and is where the card title
219
+ // moved to, so the palette stays one palette.
205
220
  color: "white",
206
- background: "#ff6d22"
221
+ background: "#c2410c"
207
222
  },
208
223
  border: "2px solid black",
209
224
  center: true,
@@ -546,6 +561,13 @@ animation: {
546
561
  return this.attachRaster(new FloatingInput()
547
562
  .set({
548
563
  type: el.inputType ?? "input",
564
+ // FloatingInput calls it `title`; every generator and every
565
+ // UI kit calls it `label`, and both solvers in the eval
566
+ // wrote `label`. The field it produced then had no
567
+ // accessible name at all — a placeholder is not one, since
568
+ // it disappears the moment anyone types.
569
+ ...(el.label !== undefined && el.title === undefined
570
+ ? { title: el.label } : {}),
549
571
  ...this.elOpts(el),
550
572
  }), el, obj.customOptions);
551
573
  }
@@ -578,6 +600,13 @@ animation: {
578
600
 
579
601
 
580
602
  static dropdown(obj){
603
+ // A label for the trigger, which is what makes every declared option
604
+ // selectable. Without one the first item becomes the label and is
605
+ // spent — `items: ["CZE","POL","DEU"]` offered two. `label` is the
606
+ // name a generator reaches for (it is what <select> and every UI kit
607
+ // call it); `title` is what the component calls it, so both are taken.
608
+ const ddLabel = obj.el.label ?? obj.el.title;
609
+
581
610
  if (!obj.el.items){
582
611
  obj.el.items = ["Flower", "Car", "Maseratti"];
583
612
  }
@@ -604,6 +633,9 @@ animation: {
604
633
  // `raster:` option here would be silently dropped.
605
634
  const dd = new Dropdown()
606
635
  .set({
636
+ // Supplied only when there is one, so a dropdown without a label
637
+ // keeps the shape it always had, byte for byte.
638
+ ...(ddLabel === undefined ? {} : { title: ddLabel }),
607
639
  behaviour: "click", // click otherwise
608
640
  //width: "120px",
609
641
 
@@ -623,7 +655,7 @@ animation: {
623
655
  new Text("Option 3"),*/
624
656
 
625
657
 
626
- new Text(obj.el.items[0]).set({
658
+ ...(ddLabel !== undefined ? [] : [new Text(obj.el.items[0]).set({
627
659
  font: "Arial",
628
660
  fluidc: "S6",
629
661
  weight: "bold",
@@ -635,10 +667,12 @@ animation: {
635
667
  },
636
668
  pad: [{l: 10, r: 10}],
637
669
 
638
- }),
670
+ })]),
639
671
 
640
672
  new Wrapper().set({border: "1px solid green", background: "#1abc9c", radius: "0.7rem", socenter: true})
641
- .add(items.slice(1)
673
+ // Every item is an option when the trigger carries its own label;
674
+ // otherwise the first is spent on the label, as before.
675
+ .add(ddLabel === undefined ? items.slice(1) : items
642
676
 
643
677
  /* new Text("Option 1o").set({font: "Arial", pad: [{a: 10}],}),
644
678
  new Text("Option 2").set({font: "Arial", pad: [{a: 10}],}),
@@ -719,8 +753,10 @@ alert("PP")
719
753
  const navItems = Array.isArray(obj.el?.items) && obj.el.items.length
720
754
  ? obj.el.items.map(navEntry)
721
755
  : null;
756
+ // `pad` gives the link a hit area: unpadded these measured 43x18,
757
+ // under the 24px WCAG 2.2 minimum in the direction a thumb needs.
722
758
  const navLinks = () => navItems.map((i) =>
723
- new Link().set({ text: i.title, url: i.link }));
759
+ new Link().set({ text: i.title, url: i.link, pad: [{ tb: 8, lr: 4 }] }));
724
760
 
725
761
  let links = items.map((i, o) => new Link(`"${i.title}"`)
726
762
  .set({
@@ -1114,6 +1150,12 @@ new Wrapper().set({
1114
1150
 
1115
1151
  // Phase S4, as protoNav above: `items` carries {title, link}; absent,
1116
1152
  // the placeholders are used exactly as before.
1153
+ // Whether the caller supplied anything, kept separate from `items`
1154
+ // itself: the placeholders carry a `link` of their own that was never
1155
+ // rendered, so honouring it now would change output that has to stay
1156
+ // byte-identical. Supplied entries use their link; placeholders keep
1157
+ // the hardcoded url they have always rendered.
1158
+ const suppliedNav = Array.isArray(obj.el?.items) && obj.el.items.length > 0;
1117
1159
  let items = Array.isArray(obj.el?.items) && obj.el.items.length
1118
1160
  ? obj.el.items.map(navEntry)
1119
1161
  : [
@@ -1139,7 +1181,11 @@ new Wrapper().set({
1139
1181
  text: i.title,
1140
1182
  link: i.link,
1141
1183
  isNavA: true,
1142
- url: "#e",
1184
+ // The caller's link, not a hardcoded one. `link:` is not read by
1185
+ // Link — `url` is what becomes the href — so declaring a link here
1186
+ // used to render a fixed value regardless, which is the silent
1187
+ // no-op this whole plan exists to remove.
1188
+ url: suppliedNav ? (i.link || "#e") : "#e",
1143
1189
  id: "#" + i.title.toLowerCase(),
1144
1190
  font: "Arial",
1145
1191
  pad: [{a:20}],
@@ -1240,7 +1286,11 @@ new Wrapper().set({
1240
1286
  text: i.title,
1241
1287
  link: i.link,
1242
1288
  isNavA: true,
1243
- url: "#myURL",
1289
+ // The caller's link, not a hardcoded one. `link:` is not read by
1290
+ // Link — `url` is what becomes the href — so declaring a link here
1291
+ // used to render a fixed value regardless, which is the silent
1292
+ // no-op this whole plan exists to remove.
1293
+ url: suppliedNav ? (i.link || "#myURL") : "#myURL",
1244
1294
  id: "#" + i.title.toLowerCase(),
1245
1295
  font: "Arial",
1246
1296
  pad: [{a:20}],
@@ -1347,7 +1397,11 @@ if (obj.el.dropdown){
1347
1397
  radius: "1rem",
1348
1398
  isSide: true,
1349
1399
  background: "#ecf0f1",
1350
- hamColour: {opened: "#1abc9c", closed: "#e67e22"},
1400
+ // Both hamburger states measured under 3:1 on white at 26px:
1401
+ // #e67e22 was 2.48:1 and #1abc9c 2.16:1. Same hues, dark enough
1402
+ // to be seen — and #c2410c is the orange the cards and the table
1403
+ // header moved to, so one palette rather than three.
1404
+ hamColour: {opened: "#0f766e", closed: "#c2410c"},
1351
1405
  mobileSize: "1.2em",
1352
1406
  fixed: true,
1353
1407
  tags: {open: "sidebar:open", close: "sidebar:closed"}
@@ -1518,6 +1572,12 @@ let elo = new Polygon({ id: "hex" })
1518
1572
  class: el.class,
1519
1573
  color: el.color,
1520
1574
  size: this.getElType(el.type), // update 23/07/2025
1575
+ //@ tag: Heading level to render, independent of the type's size.
1576
+ // Without this the option exists on the component and is not
1577
+ // reachable from a spec, which for a data-first format means it
1578
+ // does not exist. The card template uses it; an author writing
1579
+ // {type:"h5", tag:"h2"} must get the same lever.
1580
+ tag: el.tag,
1521
1581
  font: el.font ?? "Arial",
1522
1582
  // index: obj.i + "",
1523
1583
  // keySet: {key: "border", value: "3px solid green"},
@@ -1678,15 +1738,12 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1678
1738
  })
1679
1739
  .items([
1680
1740
  new Image(item.img).set({isFull: true, url: item.img}),
1681
- // #f97316 rather than the CSS keyword orange (rgb 255,165,0), a legacy
1682
- // value that reads yellow beside modern palettes. No backticks here:
1683
- // this block lives inside a template literal and is evaluated as
1684
- // source, so module scope is not visible either.
1685
- new Text(item.title).set({ size: "S5", color: "#f97316" }),
1741
+ // #c2410c is 5.18:1 on white; h2 keeps the outline intact.
1742
+ new Text(item.title).set({ size: "S5", tag: "h2", color: "#c2410c" }),
1686
1743
  new Link("Link").set({
1687
1744
  text: item.title,
1688
1745
  url: item.link,
1689
- background: "#3498db",
1746
+ background: "#1d6fe0",
1690
1747
  pad: [{ lr: "0.5rem", tb: "1rem" }],
1691
1748
  radius: "0.4rem",
1692
1749
  color: "white",
@@ -1694,6 +1751,28 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1694
1751
  })
1695
1752
  ])`;
1696
1753
 
1754
+ // WHY THE CARD'S COLOURS AND HEADING LEVEL ARE WHAT THEY ARE.
1755
+ //
1756
+ // Kept OUT of the template above on purpose. Everything inside that string
1757
+ // is source the developer receives, so a paragraph of rationale there is a
1758
+ // paragraph in every generated page: fourteen lines of commentary about
1759
+ // contrast ratios added 700 characters to the emitted output before this
1760
+ // was noticed. Moving it here — and taking the older four-line note with
1761
+ // it — left the emission 235 characters SMALLER than before this work.
1762
+ //
1763
+ // The title was #f97316, chosen over the CSS keyword orange because that
1764
+ // reads yellow beside modern palettes. A considered choice that measured
1765
+ // 2.80:1 on white against a 3:1 requirement and missed it by a margin
1766
+ // nobody catches by looking. #c2410c is 5.18:1 and clears both the large-
1767
+ // and small-text thresholds whatever size the title renders at. The link
1768
+ // background moved from #3498db (white on it: 3.15:1) to #1d6fe0 (4.77:1),
1769
+ // already this library's blue elsewhere.
1770
+ //
1771
+ // The tag option decouples the document outline from the type scale: the
1772
+ // title stays S5-sized and stops being an h5, which made every page with
1773
+ // an h1 above a card grid skip h2, h3 and h4 at once. h2, because a card
1774
+ // grid is normally a section of a page rather than a subsection of one;
1775
+ // pass tag to nest it deeper.
1697
1776
  // zoom card block (string template)
1698
1777
  const zoomCard = `
1699
1778
  new ZoomCard()
@@ -1705,11 +1784,11 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1705
1784
  useBrightness: true
1706
1785
  })
1707
1786
  .items([
1708
- new Text(item.title).set({ fluidc: "S6", color: "#f97316" }),
1787
+ new Text(item.title).set({ fluidc: "S6", tag: "h2", color: "#c2410c" }),
1709
1788
  new Link("Link").set({
1710
1789
  text: item.title,
1711
1790
  url: item.link,
1712
- background: "#3498db",
1791
+ background: "#1d6fe0",
1713
1792
  pad: [{ lr: "0.5rem", tb: "1rem" }],
1714
1793
  radius: "0.4rem",
1715
1794
  color: "white"
@@ -1806,12 +1885,23 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1806
1885
  const shorthand = items.filter((it) => !isSpecList(it));
1807
1886
  const specLists = items.filter(isSpecList);
1808
1887
 
1888
+ // Alt text, forwarded only when some entry carries it. The template
1889
+ // is emitted SOURCE, so anything added here is paid by every generated
1890
+ // page — including the no-items path, whose output is pinned byte for
1891
+ // byte. Widening the template only when the data needs it keeps that
1892
+ // pin intact and still lets the terse form describe its images, which
1893
+ // matters because the terse form is the one a generator reaches for.
1894
+ const altTemplate = (tpl) =>
1895
+ shorthand.some((it) => it && it.alt !== undefined)
1896
+ ? tpl.replace("url: item.img}", "url: item.img, alt: item.alt}")
1897
+ : tpl;
1898
+
1809
1899
  // Every entry is shorthand: keep the `.map()` form, so the emitted
1810
1900
  // code stays as compact as the hand-written equivalent and the
1811
1901
  // template is still paid once.
1812
1902
  if (specLists.length === 0) {
1813
1903
  return `${JSON.stringify(shorthand, null, 2).split("\n").map((l, i) => i === 0 ? l : " " + l).join("\n")}.map(item =>
1814
- ${cardTemplate}
1904
+ ${altTemplate(cardTemplate)}
1815
1905
  )`;
1816
1906
  }
1817
1907
 
@@ -1852,9 +1942,9 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1852
1942
  return `new Card()
1853
1943
  .set({ width: "300px", height: "700px", radius: "0.7rem", mar: { sides: ["all"], value: "0.8rem" } })
1854
1944
  .items([
1855
- new Image(${s(item.img)}).set({isFull: true, url: ${s(item.img)}}),
1856
- new Text(${s(item.title)}).set({ size: "S5", color: "#f97316" }),
1857
- new Link("Link").set({ text: ${s(item.title)}, url: ${s(item.link)}, background: "#3498db", pad: [{ lr: "0.5rem", tb: "1rem" }], radius: "0.4rem", color: "white" })
1945
+ new Image(${s(item.img)}).set({isFull: true, url: ${s(item.img)}${item.alt === undefined ? "" : `, alt: ${s(item.alt)}`}}),
1946
+ new Text(${s(item.title)}).set({ size: "S5", tag: "h2", color: "#c2410c" }),
1947
+ new Link("Link").set({ text: ${s(item.title)}, url: ${s(item.link)}, background: "#1d6fe0", pad: [{ lr: "0.5rem", tb: "1rem" }], radius: "0.4rem", color: "white" })
1858
1948
  ])`;
1859
1949
  }
1860
1950
 
@@ -2065,7 +2155,7 @@ static form(obj){
2065
2155
  .filter(l => l.op.name === name)
2066
2156
  .filter(l => {
2067
2157
  if (l.target) {
2068
- return l.target.includes(id)
2158
+ return l.target.some((t) => sameId(t, id));
2069
2159
  } else {
2070
2160
  return true;
2071
2161
  }
@@ -2116,7 +2206,7 @@ static form(obj){
2116
2206
  static filteroRaster(id, customOptions) {
2117
2207
  const list = (customOptions || []).filter((l) =>
2118
2208
  l && typeof l.op === "string" && RASTER_OP_NAMES.includes(l.op) &&
2119
- (!l.target || (id && l.target.includes(id))));
2209
+ (!l.target || (id && l.target.some((t) => sameId(t, id)))));
2120
2210
  return list.length > 0 ? list : undefined;
2121
2211
  }
2122
2212
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -8,4 +8,4 @@
8
8
  // Every parameter name any element type reads, recovered from source.
9
9
  // Used for near-miss typo detection in validate-nodes.js. A drift test
10
10
  // regenerates this and fails if it differs, so it cannot rot silently.
11
- export const ELEMENT_PARAM_NAMES = ["absolute","accept","action","align","alignIts","animate","animation","area","arrayMargin","arrayPadding","at","auto","backTo","background","backgroundCard","backgroundColor","backgroundOp","behaviour","blast","blastData","block","bold","border","borderObj","borderObja","borderRadius","brand","breakWord","breakpoints","cellAlign","cellPadding","center","centerColumn","centered","checkedBackgroundColor","children","clampc","class","clipPath","code","color","column","contentWidth","cursor","customAlign","customJustify","customStyle","data","diameter","disp","dropdown","em","exact","filter","filtera","fixMobileTap","fixed","flex","flexCenter","flexDir","fluid","fluidc","font","frame","ga","gap","gpos","gradient","hamColour","hamburgerColour","headStyle","height","hideField","hover","icon","id","img","index","initLetter","inline","inpad","inputType","isFull","isHidden","isNavA","italic","items","keySet","kind","label","layout","left","length","link","map","mar","margin","marginOp","maxHeight","maxWidth","mboth","method","mobile","multiple","name","new","nowrap","offcanvas","okayColor","onScroll","onTap","opacity","overflow","pad","padding","placeholder","position","positions","preffersId","radius","ranges","raster","react","removeDecoration","required","resmar","respad","responsive","resprop","rounded","scale","sequence","shadow","sides","simpleBorder","simpleCenter","size","span","sticky","stretch","stroke","style","svg","tags","templateCols","text","theme","tint","title","top","transform","transition","type","url","useBrightness","value","vtn","weight","width","x","y","zIndex"];
11
+ export const ELEMENT_PARAM_NAMES = ["absolute","accept","action","align","alignIts","alt","animate","animation","area","arrayMargin","arrayPadding","at","auto","backTo","background","backgroundCard","backgroundColor","backgroundOp","behaviour","blast","blastData","block","bold","border","borderObj","borderObja","borderRadius","brand","breakWord","breakpoints","cellAlign","cellPadding","center","centerColumn","centered","checkedBackgroundColor","children","clampc","class","clipPath","code","color","column","contentWidth","cursor","customAlign","customJustify","customStyle","data","diameter","disp","dropdown","em","exact","filter","filtera","fixMobileTap","fixed","flex","flexCenter","flexDir","fluid","fluidc","font","frame","ga","gap","gpos","gradient","hamColour","hamburgerColour","headStyle","height","hideField","hover","icon","id","img","index","initLetter","inline","inpad","inputType","isFull","isHidden","isNavA","italic","items","keySet","kind","label","layout","left","length","link","map","mar","margin","marginOp","maxHeight","maxWidth","mboth","method","mobile","multiple","name","new","nowrap","offcanvas","okayColor","onScroll","onTap","opacity","overflow","pad","padding","placeholder","position","positions","preffersId","radius","ranges","raster","react","removeDecoration","required","resmar","respad","responsive","resprop","rounded","scale","sequence","shadow","sides","simpleBorder","simpleCenter","size","span","sticky","stretch","stroke","style","svg","tag","tags","templateCols","text","theme","tint","title","top","transform","transition","type","url","useBrightness","value","vtn","weight","width","x","y","zIndex"];
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.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.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -142,6 +142,13 @@ const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
142
142
  const bareId = (id) =>
143
143
  typeof id === "string" && id.startsWith("#") ? id.slice(1) : id;
144
144
 
145
+ // The DOM id a component actually carries, which is not always the bare one:
146
+ // nav and sideNav emit `id="#docs"`, hash included, because animator.js used
147
+ // to require the hash to match. Both spellings are now accepted on both sides,
148
+ // and this is the lookup half — an element is found whether its id was written
149
+ // with the hash or without.
150
+ export const idMatches = (a, b) => bareId(a) === bareId(b);
151
+
145
152
  /**
146
153
  * A morph node, in one shape: a list of EDGES.
147
154
  *
@@ -0,0 +1,134 @@
1
+ /*!
2
+ * nodality v1.3.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /**
8
+ * Tier 6 of AGENTIC-FIRST-PLAN.md §10 — accept what the world already writes.
9
+ *
10
+ * MEASURED FIRST. Running claude-sonnet-5 over the 24-brief eval scored 17/24
11
+ * against 23/24 for a hand-written solver, and **four of the seven failures
12
+ * were one mistake wearing different clothes**: the model reached for the name
13
+ * HTML uses and the library wanted another.
14
+ *
15
+ * src -> url an <img> has src. Two briefs lost to this.
16
+ * href -> url an <a> has href.
17
+ * options -> items a <select> has options.
18
+ *
19
+ * These are not typos, so the did-you-mean built in Stage 3 cannot reach them:
20
+ * `src` is three edits from `url`. And a model writing `src` for an image is
21
+ * not confused — it is applying the most widely attested convention there is.
22
+ * The divergence bought nothing and cost three briefs in twenty-four.
23
+ *
24
+ * The same measurement found one more failure of the same shape rather than
25
+ * the same name: a table's rows written as arrays, header row first, which is
26
+ * how every CSV and every markdown table on earth is laid out. That is handled
27
+ * here too — see `normalizeShapes`.
28
+ *
29
+ * Purely additive. A spec that uses the canonical names and shapes is
30
+ * untouched, so no existing page can observe this. Where both are present the canonical name
31
+ * wins and the collision is REPORTED rather than silently resolved, because
32
+ * quietly dropping a key the author wrote is the exact failure this project
33
+ * has spent every stage removing.
34
+ */
35
+
36
+ /** alias -> canonical. Deliberately short: each entry has to earn its place by
37
+ * being what a generator actually writes, not by being plausible. */
38
+ export const ALIASES = Object.freeze({
39
+ src: "url",
40
+ href: "url",
41
+ options: "items",
42
+ });
43
+
44
+ const CANONICAL = new Set(Object.values(ALIASES));
45
+
46
+ /** Walk children and items as well, so nested specs get the same treatment. */
47
+ const walk = (node, fn) => {
48
+ if (Array.isArray(node)) return node.map((n) => walk(n, fn));
49
+ if (!node || typeof node !== "object") return node;
50
+ const out = fn({ ...node });
51
+ for (const key of ["children", "items"]) {
52
+ if (out[key] !== undefined) out[key] = walk(out[key], fn);
53
+ }
54
+ return out;
55
+ };
56
+
57
+ /** Rewrite every alias to its canonical name. */
58
+ export function normalizeAliases(elements) {
59
+ return walk(elements, (node) => {
60
+ for (const [alias, canonical] of Object.entries(ALIASES)) {
61
+ if (!Object.prototype.hasOwnProperty.call(node, alias)) continue;
62
+ // Canonical wins. `aliasConflicts` is what tells the author.
63
+ if (!Object.prototype.hasOwnProperty.call(node, canonical)) {
64
+ node[canonical] = node[alias];
65
+ }
66
+ delete node[alias];
67
+ }
68
+ return node;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Places where an alias and its canonical name are both present with
74
+ * DIFFERENT values — one of them is going to be ignored, and the author should
75
+ * hear it from the validator rather than discover it in the page.
76
+ */
77
+ export function aliasConflicts(elements, at = "elements") {
78
+ const found = [];
79
+ const visit = (node, path) => {
80
+ if (Array.isArray(node)) return node.forEach((n, i) => visit(n, `${path}[${i}]`));
81
+ if (!node || typeof node !== "object") return;
82
+ for (const [alias, canonical] of Object.entries(ALIASES)) {
83
+ if (Object.prototype.hasOwnProperty.call(node, alias) &&
84
+ Object.prototype.hasOwnProperty.call(node, canonical) &&
85
+ JSON.stringify(node[alias]) !== JSON.stringify(node[canonical])) {
86
+ found.push({ path: `${path}.${alias}`, alias, canonical });
87
+ }
88
+ }
89
+ for (const key of ["children", "items"]) {
90
+ if (node[key] !== undefined) visit(node[key], `${path}.${key}`);
91
+ }
92
+ };
93
+ visit(elements, at);
94
+ return found;
95
+ }
96
+
97
+ /** Every name this module accepts, for the validator's vocabulary. */
98
+ export const ALIAS_NAMES = Object.freeze(Object.keys(ALIASES));
99
+ export const isCanonical = (name) => CANONICAL.has(name);
100
+
101
+
102
+ /**
103
+ * Shapes that are not the library's but are what everyone writes.
104
+ *
105
+ * A table's rows as ARRAYS, header row first:
106
+ *
107
+ * items: [["date","race"], ["29/08","Krusnoman"]]
108
+ * -> items: [{ date: "29/08", race: "Krusnoman" }]
109
+ *
110
+ * The model in the Tier 2b run wrote exactly this, and it is the layout of
111
+ * every CSV and every markdown table there is. Header-first is not a guess:
112
+ * a table whose first row is data has no column names at all, and the mapper
113
+ * needs them to build the head.
114
+ *
115
+ * Narrow on purpose — `table` only, and only when EVERY entry is an array.
116
+ * A mixed list is left alone rather than half-converted.
117
+ */
118
+ export function normalizeShapes(elements) {
119
+ return walk(elements, (node) => {
120
+ if (node.type !== "table" || !Array.isArray(node.items) || node.items.length < 2) return node;
121
+ if (!node.items.every((r) => Array.isArray(r))) return node;
122
+
123
+ const [head, ...rows] = node.items;
124
+ if (!head.every((h) => typeof h === "string" || typeof h === "number")) return node;
125
+ node.items = rows.map((row) =>
126
+ Object.fromEntries(head.map((key, i) => [String(key), row[i]])));
127
+ return node;
128
+ });
129
+ }
130
+
131
+ /** Both passes, in the order the caller wants: names first, then shapes. */
132
+ export function normalizeSpec(elements) {
133
+ return normalizeShapes(normalizeAliases(elements));
134
+ }
package/lib/parse-html.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -47,6 +47,8 @@
47
47
  * this stage across mixed leaf-and-composite specs.
48
48
  */
49
49
 
50
+ import { validateNodes } from "./validate-nodes.js";
51
+
50
52
  const HEADINGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
51
53
 
52
54
  /** The attribute a page uses to carry its own descriptors. */
@@ -129,11 +131,21 @@ export function parseReport(html, doc) {
129
131
  if (one) spec.push(one);
130
132
  else unrecovered.push({ index: i, tag: (node.tagName || "").toLowerCase() });
131
133
  });
134
+ // What parse returns is UNTRUSTED. A `data-nod` attribute is just text in
135
+ // a document: hand-edited, served by someone else, or written by a version
136
+ // of the library that is not this one. The caller's next move is to render
137
+ // it, so the recovered spec is checked here rather than after it has
138
+ // already become a page. This is also the first place Stage 3 and Stage 5
139
+ // compose — the validator that makes a spec repairable is the one that
140
+ // makes a parsed spec safe.
141
+ const check = validateNodes([], spec);
142
+
132
143
  return {
133
- ok: unrecovered.length === 0,
144
+ ok: unrecovered.length === 0 && check.ok,
134
145
  exact: kids.every((n) => n.getAttribute && n.getAttribute(SPEC_ATTR)),
135
146
  total: kids.length,
136
147
  spec,
137
148
  unrecovered,
149
+ errors: check.errors,
138
150
  };
139
151
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.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.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.10
2
+ * nodality v1.3.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */