nodality 1.2.11 → 1.3.1

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 (282) hide show
  1. package/bin/mcp-server.mjs +45 -0
  2. package/dist/animator.cjs.js +1 -1
  3. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  4. package/dist/animator.esm.js +1 -1
  5. package/dist/animator.esm.js.LICENSE.txt +1 -1
  6. package/dist/audionew.cjs.js +1 -1
  7. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  8. package/dist/audionew.esm.js +1 -1
  9. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  10. package/dist/base.cjs.js +1 -1
  11. package/dist/base.cjs.js.LICENSE.txt +1 -1
  12. package/dist/base.esm.js +1 -1
  13. package/dist/base.esm.js.LICENSE.txt +1 -1
  14. package/dist/beta-desktop-bar.cjs.js +1 -1
  15. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  16. package/dist/beta-desktop-bar.esm.js +1 -1
  17. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  18. package/dist/beta-mobile-bar.cjs.js +1 -1
  19. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  20. package/dist/beta-mobile-bar.esm.js +1 -1
  21. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  22. package/dist/bundle.umd.js +1 -1
  23. package/dist/bundle.umd.js.LICENSE.txt +1 -1
  24. package/dist/button.cjs.js +1 -1
  25. package/dist/button.cjs.js.LICENSE.txt +1 -1
  26. package/dist/button.esm.js +1 -1
  27. package/dist/button.esm.js.LICENSE.txt +1 -1
  28. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  29. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  30. package/dist/center.cjs.js +1 -1
  31. package/dist/center.cjs.js.LICENSE.txt +1 -1
  32. package/dist/center.esm.js +1 -1
  33. package/dist/center.esm.js.LICENSE.txt +1 -1
  34. package/dist/checkbox.cjs.js +1 -1
  35. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  36. package/dist/checkbox.esm.js +1 -1
  37. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  38. package/dist/code.cjs.js +1 -1
  39. package/dist/code.cjs.js.LICENSE.txt +1 -1
  40. package/dist/code.esm.js +1 -1
  41. package/dist/code.esm.js.LICENSE.txt +1 -1
  42. package/dist/container.cjs.js +1 -1
  43. package/dist/container.cjs.js.LICENSE.txt +1 -1
  44. package/dist/container.esm.js +1 -1
  45. package/dist/container.esm.js.LICENSE.txt +1 -1
  46. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  47. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  48. package/dist/designer.cjs.js +1 -1
  49. package/dist/designer.cjs.js.LICENSE.txt +1 -1
  50. package/dist/designer.esm.js +1 -1
  51. package/dist/designer.esm.js.LICENSE.txt +1 -1
  52. package/dist/element-mapper.cjs.js +1 -1
  53. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  54. package/dist/element-mapper.esm.js +1 -1
  55. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  56. package/dist/finalresult.esm.js +1 -1
  57. package/dist/finalresult.esm.js.LICENSE.txt +1 -1
  58. package/dist/flex-card.cjs.js +1 -1
  59. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  60. package/dist/flex-card.esm.js +1 -1
  61. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  62. package/dist/flex-grid.cjs.js +1 -1
  63. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  64. package/dist/flex-grid.esm.js +1 -1
  65. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  66. package/dist/flex-row.cjs.js +1 -1
  67. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  68. package/dist/flex-row.esm.js +1 -1
  69. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  70. package/dist/floating-input.cjs.js +1 -1
  71. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  72. package/dist/floating-input.esm.js +1 -1
  73. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  74. package/dist/free.cjs.js +1 -1
  75. package/dist/free.cjs.js.LICENSE.txt +1 -1
  76. package/dist/free.esm.js +1 -1
  77. package/dist/free.esm.js.LICENSE.txt +1 -1
  78. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  79. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  80. package/dist/image-picker.cjs.js +1 -1
  81. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  82. package/dist/image-picker.esm.js +1 -1
  83. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  84. package/dist/image.cjs.js +1 -1
  85. package/dist/image.cjs.js.LICENSE.txt +1 -1
  86. package/dist/image.esm.js +1 -1
  87. package/dist/image.esm.js.LICENSE.txt +1 -1
  88. package/dist/index.cjs.js +1 -1
  89. package/dist/index.cjs.js.LICENSE.txt +1 -1
  90. package/dist/index.d.ts +3 -2
  91. package/dist/index.esm.js +1 -1
  92. package/dist/index.esm.js.LICENSE.txt +1 -1
  93. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  94. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  95. package/dist/link-getter.cjs.js +1 -1
  96. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  97. package/dist/link-getter.esm.js +1 -1
  98. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  99. package/dist/link.cjs.js +1 -1
  100. package/dist/link.cjs.js.LICENSE.txt +1 -1
  101. package/dist/link.esm.js +1 -1
  102. package/dist/link.esm.js.LICENSE.txt +1 -1
  103. package/dist/meta-adder.cjs.js +1 -1
  104. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  105. package/dist/meta-adder.esm.js +1 -1
  106. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  107. package/dist/modal-2025.cjs.js +1 -1
  108. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  109. package/dist/modal-2025.esm.js +1 -1
  110. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  111. package/dist/multiswitcher.cjs.js +1 -1
  112. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  113. package/dist/multiswitcher.esm.js +1 -1
  114. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  115. package/dist/new-nav-bar.cjs.js +1 -1
  116. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  117. package/dist/new-nav-bar.esm.js +1 -1
  118. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  119. package/dist/picker.cjs.js +1 -1
  120. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  121. package/dist/picker.esm.js +1 -1
  122. package/dist/picker.esm.js.LICENSE.txt +1 -1
  123. package/dist/progress.cjs.js +1 -1
  124. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  125. package/dist/progress.esm.js +1 -1
  126. package/dist/progress.esm.js.LICENSE.txt +1 -1
  127. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  128. package/dist/radio.esm.js.LICENSE.txt +1 -1
  129. package/dist/range.cjs.js.LICENSE.txt +1 -1
  130. package/dist/range.esm.js.LICENSE.txt +1 -1
  131. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  132. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  133. package/dist/side-bar.cjs.js +1 -1
  134. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  135. package/dist/side-bar.esm.js +1 -1
  136. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  137. package/dist/side-nav-bar.cjs.js +1 -1
  138. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  139. package/dist/side-nav-bar.esm.js +1 -1
  140. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  141. package/dist/simple-bar.cjs.js +1 -1
  142. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  143. package/dist/simple-bar.esm.js +1 -1
  144. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  145. package/dist/slider-2025.cjs.js +1 -1
  146. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  147. package/dist/slider-2025.esm.js +1 -1
  148. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  149. package/dist/spacer.cjs.js +1 -1
  150. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  151. package/dist/spacer.esm.js +1 -1
  152. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  153. package/dist/stack.cjs.js +1 -1
  154. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  155. package/dist/stack.esm.js +1 -1
  156. package/dist/stack.esm.js.LICENSE.txt +1 -1
  157. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  158. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  159. package/dist/table.cjs.js +1 -1
  160. package/dist/table.cjs.js.LICENSE.txt +1 -1
  161. package/dist/table.esm.js +1 -1
  162. package/dist/table.esm.js.LICENSE.txt +1 -1
  163. package/dist/text-field.cjs.js +1 -1
  164. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  165. package/dist/text-field.esm.js +1 -1
  166. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  167. package/dist/text.cjs.js +1 -1
  168. package/dist/text.cjs.js.LICENSE.txt +1 -1
  169. package/dist/text.esm.js +1 -1
  170. package/dist/text.esm.js.LICENSE.txt +1 -1
  171. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  172. package/dist/theme.esm.js.LICENSE.txt +1 -1
  173. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  174. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  175. package/dist/ulist.cjs.js +1 -1
  176. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  177. package/dist/ulist.esm.js +1 -1
  178. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  179. package/dist/video.cjs.js +1 -1
  180. package/dist/video.cjs.js.LICENSE.txt +1 -1
  181. package/dist/video.esm.js +1 -1
  182. package/dist/video.esm.js.LICENSE.txt +1 -1
  183. package/dist/wrap.cjs.js +1 -1
  184. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  185. package/dist/wrap.esm.js +1 -1
  186. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  187. package/dist/zoom-card.cjs.js +1 -1
  188. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  189. package/dist/zoom-card.esm.js +1 -1
  190. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  191. package/layout/animator.js +15 -2
  192. package/layout/audio.js +1 -1
  193. package/layout/audionew.js +1 -1
  194. package/layout/base.js +1 -1
  195. package/layout/beta-desktop-bar.js +1 -1
  196. package/layout/beta-mobile-bar.js +1 -1
  197. package/layout/button.js +1 -1
  198. package/layout/center.js +1 -1
  199. package/layout/checkbox.js +1 -1
  200. package/layout/circle.js +1 -1
  201. package/layout/code.js +1 -1
  202. package/layout/container.js +1 -1
  203. package/layout/dropdown-2025.js +10 -3
  204. package/layout/flex-card.js +1 -1
  205. package/layout/flex-grid.js +1 -1
  206. package/layout/flex-row.js +1 -1
  207. package/layout/form-components/custom.js +1 -1
  208. package/layout/form-components/data-list.js +1 -1
  209. package/layout/form-components/floating-input.js +23 -4
  210. package/layout/form-components/form-all.js +1 -1
  211. package/layout/form-components/form.js +1 -1
  212. package/layout/form-components/image-picker.js +5 -3
  213. package/layout/form-components/picker.js +1 -1
  214. package/layout/form-components/radio.js +1 -1
  215. package/layout/form-components/radiogroup.js +1 -1
  216. package/layout/form-components/range.js +1 -1
  217. package/layout/free.js +1 -1
  218. package/layout/grid-switcher.js +1 -1
  219. package/layout/grid.js +1 -1
  220. package/layout/horizontal-scroller.js +1 -1
  221. package/layout/image.js +15 -2
  222. package/layout/index.js +1 -1
  223. package/layout/link.js +1 -1
  224. package/layout/list.js +1 -1
  225. package/layout/meta-adder.js +1 -1
  226. package/layout/modal-2025.js +1 -1
  227. package/layout/morph.js +1 -1
  228. package/layout/multiswitcher.js +1 -1
  229. package/layout/nav-bar.js +1 -1
  230. package/layout/nav-factor/custom-div.js +1 -1
  231. package/layout/new-nav-bar.js +1 -1
  232. package/layout/polygon.js +1 -1
  233. package/layout/prerender-site.js +1 -1
  234. package/layout/prerender.js +1 -1
  235. package/layout/progress.js +1 -1
  236. package/layout/row.js +1 -1
  237. package/layout/scroll-video.js +1 -1
  238. package/layout/side-bar.js +11 -3
  239. package/layout/side-nav-bar.js +16 -3
  240. package/layout/simple-bar.js +1 -1
  241. package/layout/slider-2025.js +1 -1
  242. package/layout/spacer.js +1 -1
  243. package/layout/stack.js +1 -1
  244. package/layout/svg.js +1 -1
  245. package/layout/switcher.js +1 -1
  246. package/layout/table.js +1 -1
  247. package/layout/text-field.js +16 -1
  248. package/layout/text.js +28 -1
  249. package/layout/ulist.js +1 -1
  250. package/layout/video.js +1 -1
  251. package/layout/wrap.js +1 -1
  252. package/layout/zoom-card.js +1 -1
  253. package/lib/agent-surface.js +1 -1
  254. package/lib/card-getter.js +1 -1
  255. package/lib/check-page.js +348 -0
  256. package/lib/codegen.js +1 -1
  257. package/lib/data.js +1 -1
  258. package/lib/designer.js +64 -2
  259. package/lib/element-mapper.js +121 -23
  260. package/lib/element-params.generated.js +2 -2
  261. package/lib/keyframe-animation.js +1 -1
  262. package/lib/link-getter.js +1 -1
  263. package/lib/morph-node.js +8 -1
  264. package/lib/normalize-spec.js +134 -0
  265. package/lib/parse-html.js +1 -1
  266. package/lib/raster-inspect.js +1 -1
  267. package/lib/raster-ops.js +1 -1
  268. package/lib/raster-presets.js +1 -1
  269. package/lib/resolve-refs.js +121 -0
  270. package/lib/scroll-video.js +1 -1
  271. package/lib/seo.js +1 -1
  272. package/lib/stacker.js +1 -1
  273. package/lib/suggest.js +1 -1
  274. package/lib/theme.js +1 -1
  275. package/lib/transform-anim.js +1 -1
  276. package/lib/transition.js +1 -1
  277. package/lib/validate-nodes.js +111 -3
  278. package/lib/webmcp-adapter.js +1 -1
  279. package/package.json +3 -1
  280. package/schema.json +973 -12
  281. package/scripts/generate-schema.mjs +9 -1
  282. package/skills/nodality/SKILL.md +50 -0
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
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,21 @@ 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
+ //
570
+ // `text` joined them after Tier 7. A model wrote
571
+ // `{type:"labelInput", text:"Name"}` and the field rendered
572
+ // the literal string "undefined" as its label, its
573
+ // placeholder and its accessible name, while validate_nodes
574
+ // reported ok — the worst available outcome for a spelling
575
+ // this natural. Tier 6's rule applies: take what the world
576
+ // already writes. `title` still wins, then `label`.
577
+ ...(el.title === undefined && (el.label !== undefined || el.text !== undefined)
578
+ ? { title: el.label !== undefined ? el.label : el.text } : {}),
549
579
  ...this.elOpts(el),
550
580
  }), el, obj.customOptions);
551
581
  }
@@ -578,6 +608,13 @@ animation: {
578
608
 
579
609
 
580
610
  static dropdown(obj){
611
+ // A label for the trigger, which is what makes every declared option
612
+ // selectable. Without one the first item becomes the label and is
613
+ // spent — `items: ["CZE","POL","DEU"]` offered two. `label` is the
614
+ // name a generator reaches for (it is what <select> and every UI kit
615
+ // call it); `title` is what the component calls it, so both are taken.
616
+ const ddLabel = obj.el.label ?? obj.el.title;
617
+
581
618
  if (!obj.el.items){
582
619
  obj.el.items = ["Flower", "Car", "Maseratti"];
583
620
  }
@@ -604,6 +641,9 @@ animation: {
604
641
  // `raster:` option here would be silently dropped.
605
642
  const dd = new Dropdown()
606
643
  .set({
644
+ // Supplied only when there is one, so a dropdown without a label
645
+ // keeps the shape it always had, byte for byte.
646
+ ...(ddLabel === undefined ? {} : { title: ddLabel }),
607
647
  behaviour: "click", // click otherwise
608
648
  //width: "120px",
609
649
 
@@ -623,7 +663,7 @@ animation: {
623
663
  new Text("Option 3"),*/
624
664
 
625
665
 
626
- new Text(obj.el.items[0]).set({
666
+ ...(ddLabel !== undefined ? [] : [new Text(obj.el.items[0]).set({
627
667
  font: "Arial",
628
668
  fluidc: "S6",
629
669
  weight: "bold",
@@ -635,10 +675,12 @@ animation: {
635
675
  },
636
676
  pad: [{l: 10, r: 10}],
637
677
 
638
- }),
678
+ })]),
639
679
 
640
680
  new Wrapper().set({border: "1px solid green", background: "#1abc9c", radius: "0.7rem", socenter: true})
641
- .add(items.slice(1)
681
+ // Every item is an option when the trigger carries its own label;
682
+ // otherwise the first is spent on the label, as before.
683
+ .add(ddLabel === undefined ? items.slice(1) : items
642
684
 
643
685
  /* new Text("Option 1o").set({font: "Arial", pad: [{a: 10}],}),
644
686
  new Text("Option 2").set({font: "Arial", pad: [{a: 10}],}),
@@ -719,8 +761,10 @@ alert("PP")
719
761
  const navItems = Array.isArray(obj.el?.items) && obj.el.items.length
720
762
  ? obj.el.items.map(navEntry)
721
763
  : null;
764
+ // `pad` gives the link a hit area: unpadded these measured 43x18,
765
+ // under the 24px WCAG 2.2 minimum in the direction a thumb needs.
722
766
  const navLinks = () => navItems.map((i) =>
723
- new Link().set({ text: i.title, url: i.link }));
767
+ new Link().set({ text: i.title, url: i.link, pad: [{ tb: 8, lr: 4 }] }));
724
768
 
725
769
  let links = items.map((i, o) => new Link(`"${i.title}"`)
726
770
  .set({
@@ -1114,6 +1158,12 @@ new Wrapper().set({
1114
1158
 
1115
1159
  // Phase S4, as protoNav above: `items` carries {title, link}; absent,
1116
1160
  // the placeholders are used exactly as before.
1161
+ // Whether the caller supplied anything, kept separate from `items`
1162
+ // itself: the placeholders carry a `link` of their own that was never
1163
+ // rendered, so honouring it now would change output that has to stay
1164
+ // byte-identical. Supplied entries use their link; placeholders keep
1165
+ // the hardcoded url they have always rendered.
1166
+ const suppliedNav = Array.isArray(obj.el?.items) && obj.el.items.length > 0;
1117
1167
  let items = Array.isArray(obj.el?.items) && obj.el.items.length
1118
1168
  ? obj.el.items.map(navEntry)
1119
1169
  : [
@@ -1139,7 +1189,11 @@ new Wrapper().set({
1139
1189
  text: i.title,
1140
1190
  link: i.link,
1141
1191
  isNavA: true,
1142
- url: "#e",
1192
+ // The caller's link, not a hardcoded one. `link:` is not read by
1193
+ // Link — `url` is what becomes the href — so declaring a link here
1194
+ // used to render a fixed value regardless, which is the silent
1195
+ // no-op this whole plan exists to remove.
1196
+ url: suppliedNav ? (i.link || "#e") : "#e",
1143
1197
  id: "#" + i.title.toLowerCase(),
1144
1198
  font: "Arial",
1145
1199
  pad: [{a:20}],
@@ -1240,7 +1294,11 @@ new Wrapper().set({
1240
1294
  text: i.title,
1241
1295
  link: i.link,
1242
1296
  isNavA: true,
1243
- url: "#myURL",
1297
+ // The caller's link, not a hardcoded one. `link:` is not read by
1298
+ // Link — `url` is what becomes the href — so declaring a link here
1299
+ // used to render a fixed value regardless, which is the silent
1300
+ // no-op this whole plan exists to remove.
1301
+ url: suppliedNav ? (i.link || "#myURL") : "#myURL",
1244
1302
  id: "#" + i.title.toLowerCase(),
1245
1303
  font: "Arial",
1246
1304
  pad: [{a:20}],
@@ -1347,7 +1405,11 @@ if (obj.el.dropdown){
1347
1405
  radius: "1rem",
1348
1406
  isSide: true,
1349
1407
  background: "#ecf0f1",
1350
- hamColour: {opened: "#1abc9c", closed: "#e67e22"},
1408
+ // Both hamburger states measured under 3:1 on white at 26px:
1409
+ // #e67e22 was 2.48:1 and #1abc9c 2.16:1. Same hues, dark enough
1410
+ // to be seen — and #c2410c is the orange the cards and the table
1411
+ // header moved to, so one palette rather than three.
1412
+ hamColour: {opened: "#0f766e", closed: "#c2410c"},
1351
1413
  mobileSize: "1.2em",
1352
1414
  fixed: true,
1353
1415
  tags: {open: "sidebar:open", close: "sidebar:closed"}
@@ -1518,6 +1580,12 @@ let elo = new Polygon({ id: "hex" })
1518
1580
  class: el.class,
1519
1581
  color: el.color,
1520
1582
  size: this.getElType(el.type), // update 23/07/2025
1583
+ //@ tag: Heading level to render, independent of the type's size.
1584
+ // Without this the option exists on the component and is not
1585
+ // reachable from a spec, which for a data-first format means it
1586
+ // does not exist. The card template uses it; an author writing
1587
+ // {type:"h5", tag:"h2"} must get the same lever.
1588
+ tag: el.tag,
1521
1589
  font: el.font ?? "Arial",
1522
1590
  // index: obj.i + "",
1523
1591
  // keySet: {key: "border", value: "3px solid green"},
@@ -1678,15 +1746,12 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1678
1746
  })
1679
1747
  .items([
1680
1748
  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" }),
1749
+ // #c2410c is 5.18:1 on white; h2 keeps the outline intact.
1750
+ new Text(item.title).set({ size: "S5", tag: "h2", color: "#c2410c" }),
1686
1751
  new Link("Link").set({
1687
1752
  text: item.title,
1688
1753
  url: item.link,
1689
- background: "#3498db",
1754
+ background: "#1d6fe0",
1690
1755
  pad: [{ lr: "0.5rem", tb: "1rem" }],
1691
1756
  radius: "0.4rem",
1692
1757
  color: "white",
@@ -1694,6 +1759,28 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1694
1759
  })
1695
1760
  ])`;
1696
1761
 
1762
+ // WHY THE CARD'S COLOURS AND HEADING LEVEL ARE WHAT THEY ARE.
1763
+ //
1764
+ // Kept OUT of the template above on purpose. Everything inside that string
1765
+ // is source the developer receives, so a paragraph of rationale there is a
1766
+ // paragraph in every generated page: fourteen lines of commentary about
1767
+ // contrast ratios added 700 characters to the emitted output before this
1768
+ // was noticed. Moving it here — and taking the older four-line note with
1769
+ // it — left the emission 235 characters SMALLER than before this work.
1770
+ //
1771
+ // The title was #f97316, chosen over the CSS keyword orange because that
1772
+ // reads yellow beside modern palettes. A considered choice that measured
1773
+ // 2.80:1 on white against a 3:1 requirement and missed it by a margin
1774
+ // nobody catches by looking. #c2410c is 5.18:1 and clears both the large-
1775
+ // and small-text thresholds whatever size the title renders at. The link
1776
+ // background moved from #3498db (white on it: 3.15:1) to #1d6fe0 (4.77:1),
1777
+ // already this library's blue elsewhere.
1778
+ //
1779
+ // The tag option decouples the document outline from the type scale: the
1780
+ // title stays S5-sized and stops being an h5, which made every page with
1781
+ // an h1 above a card grid skip h2, h3 and h4 at once. h2, because a card
1782
+ // grid is normally a section of a page rather than a subsection of one;
1783
+ // pass tag to nest it deeper.
1697
1784
  // zoom card block (string template)
1698
1785
  const zoomCard = `
1699
1786
  new ZoomCard()
@@ -1705,11 +1792,11 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1705
1792
  useBrightness: true
1706
1793
  })
1707
1794
  .items([
1708
- new Text(item.title).set({ fluidc: "S6", color: "#f97316" }),
1795
+ new Text(item.title).set({ fluidc: "S6", tag: "h2", color: "#c2410c" }),
1709
1796
  new Link("Link").set({
1710
1797
  text: item.title,
1711
1798
  url: item.link,
1712
- background: "#3498db",
1799
+ background: "#1d6fe0",
1713
1800
  pad: [{ lr: "0.5rem", tb: "1rem" }],
1714
1801
  radius: "0.4rem",
1715
1802
  color: "white"
@@ -1806,12 +1893,23 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1806
1893
  const shorthand = items.filter((it) => !isSpecList(it));
1807
1894
  const specLists = items.filter(isSpecList);
1808
1895
 
1896
+ // Alt text, forwarded only when some entry carries it. The template
1897
+ // is emitted SOURCE, so anything added here is paid by every generated
1898
+ // page — including the no-items path, whose output is pinned byte for
1899
+ // byte. Widening the template only when the data needs it keeps that
1900
+ // pin intact and still lets the terse form describe its images, which
1901
+ // matters because the terse form is the one a generator reaches for.
1902
+ const altTemplate = (tpl) =>
1903
+ shorthand.some((it) => it && it.alt !== undefined)
1904
+ ? tpl.replace("url: item.img}", "url: item.img, alt: item.alt}")
1905
+ : tpl;
1906
+
1809
1907
  // Every entry is shorthand: keep the `.map()` form, so the emitted
1810
1908
  // code stays as compact as the hand-written equivalent and the
1811
1909
  // template is still paid once.
1812
1910
  if (specLists.length === 0) {
1813
1911
  return `${JSON.stringify(shorthand, null, 2).split("\n").map((l, i) => i === 0 ? l : " " + l).join("\n")}.map(item =>
1814
- ${cardTemplate}
1912
+ ${altTemplate(cardTemplate)}
1815
1913
  )`;
1816
1914
  }
1817
1915
 
@@ -1852,9 +1950,9 @@ const blast = this.filtero("blast", el.id, obj.customOptions);
1852
1950
  return `new Card()
1853
1951
  .set({ width: "300px", height: "700px", radius: "0.7rem", mar: { sides: ["all"], value: "0.8rem" } })
1854
1952
  .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" })
1953
+ new Image(${s(item.img)}).set({isFull: true, url: ${s(item.img)}${item.alt === undefined ? "" : `, alt: ${s(item.alt)}`}}),
1954
+ new Text(${s(item.title)}).set({ size: "S5", tag: "h2", color: "#c2410c" }),
1955
+ 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
1956
  ])`;
1859
1957
  }
1860
1958
 
@@ -2065,7 +2163,7 @@ static form(obj){
2065
2163
  .filter(l => l.op.name === name)
2066
2164
  .filter(l => {
2067
2165
  if (l.target) {
2068
- return l.target.includes(id)
2166
+ return l.target.some((t) => sameId(t, id));
2069
2167
  } else {
2070
2168
  return true;
2071
2169
  }
@@ -2116,7 +2214,7 @@ static form(obj){
2116
2214
  static filteroRaster(id, customOptions) {
2117
2215
  const list = (customOptions || []).filter((l) =>
2118
2216
  l && typeof l.op === "string" && RASTER_OP_NAMES.includes(l.op) &&
2119
- (!l.target || (id && l.target.includes(id))));
2217
+ (!l.target || (id && l.target.some((t) => sameId(t, id)))));
2120
2218
  return list.length > 0 ? list : undefined;
2121
2219
  }
2122
2220
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
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","alignTo","aligns","alt","animate","animation","area","arrayMargin","arrayPadding","as","at","auto","backTo","background","backgroundCard","backgroundColor","backgroundOp","behaviour","blast","blastData","block","bold","border","borderObj","borderObja","borderRadius","brand","breakWord","breakpoints","cellAlign","cellPadding","center","centerColumn","centerSelf","centered","checkedBackgroundColor","child","children","clampc","class","clipPath","code","colat","color","column","contentWidth","cursor","customAlign","customJustify","customStyle","data","decoding","diameter","disp","dropdown","em","exact","fetchPriority","filter","filtera","fitContent","fixMobileTap","fixed","flex","flexCenter","flexDir","fluid","fluidc","font","frame","ga","gap","gpos","gradient","hamColour","hamburgerColour","headStyle","height","hideField","hideOverflow","hover","icon","id","img","index","initLetter","inline","inpad","inputType","isBackground","isFull","isHidden","isLink","isNavA","italic","items","justifo","justify","keySet","kind","label","layout","left","length","link","loading","map","mar","margin","marginOp","maxHeight","maxWidth","mboth","method","minHeight","mobile","multiple","name","new","nowrap","objectFit","objectPosition","offcanvas","okayColor","onScroll","onTap","opacity","overflow","owrap","pad","padding","placeholder","position","positions","preffersId","radius","ranges","raster","react","removeDecoration","required","resmar","respad","responsive","resprop","rounded","scale","sequence","shadow","sides","simpleBorder","simpleCenter","size","sizes","span","srcset","sticky","stretch","stroke","style","stype","svg","tag","tags","templateCols","text","theme","tint","title","toColumn","top","transform","transition","type","url","useBrightness","value","vtn","weight","width","wrap","x","y","zIndex"];
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
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.11
2
+ * nodality v1.3.1
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.1
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.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
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.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -0,0 +1,121 @@
1
+ /*!
2
+ * nodality v1.3.1
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /**
8
+ * Tier 3 of AGENTIC-FIRST-PLAN.md §10 — declare shared structure once.
9
+ *
10
+ * MEASURED FIRST, as the plan asked. Chrome taken from a real site: a nav and
11
+ * a footer are 184 tokens, and **61% of every token in that site is the same
12
+ * nav and footer**. Referencing rather than repeating them is 1.98x at ten
13
+ * pages and 2.10x at twenty — and a NET LOSS at one page, which is why this is
14
+ * opt-in and why nothing changes for a caller who never uses it.
15
+ *
16
+ * The reason it is needed is not that the data format is verbose. The site
17
+ * used for that measurement does not repeat itself: it imports `renderNav`
18
+ * from a shared module and calls it. That is the imperative layer, where reuse
19
+ * is just a function. An element array cannot call a function, so an agent
20
+ * authoring as data pays for the chrome once per page, and the only way out is
21
+ * to drop into code — which is what property 1, "total", exists to prevent.
22
+ * A human never feels this and an agent always does.
23
+ *
24
+ * new Des()
25
+ * .defs({ nav: { type: "nav", items: [...] } })
26
+ * .add([{ $ref: "nav" }, { type: "h1", text: "Page" }])
27
+ *
28
+ * The definitions are ordinary data, so they serialise, ship in one file, and
29
+ * are imported by every page's entry — the sharing is a plain import of a
30
+ * plain object, not a new authoring mode.
31
+ */
32
+
33
+ /** The key that marks a reference. `$`-prefixed so it cannot collide with a
34
+ * parameter name: the schema's vocabulary is all bare identifiers. */
35
+ export const REF_KEY = "$ref";
36
+
37
+ /** True for `{ $ref: "name" }` and nothing else. */
38
+ export const isRef = (v) =>
39
+ !!v && typeof v === "object" && !Array.isArray(v) && typeof v[REF_KEY] === "string";
40
+
41
+ /** Every name referenced anywhere in a spec, with the path that reached it. */
42
+ export function collectRefs(elements, at = "elements") {
43
+ const found = [];
44
+ const walk = (node, path) => {
45
+ if (Array.isArray(node)) return node.forEach((n, i) => walk(n, `${path}[${i}]`));
46
+ if (!node || typeof node !== "object") return;
47
+ if (isRef(node)) { found.push({ name: node[REF_KEY], path }); return; }
48
+ for (const key of ["children", "items"]) {
49
+ if (node[key] !== undefined) walk(node[key], `${path}.${key}`);
50
+ }
51
+ };
52
+ walk(elements, at);
53
+ return found;
54
+ }
55
+
56
+ /**
57
+ * Replace every `{ $ref: name }` with a deep copy of `defs[name]`.
58
+ *
59
+ * A copy, not the definition itself: two pages referencing one nav must not
60
+ * share an object the mapper then mutates — rendering an `a` already adds
61
+ * `font` and `pad` to the descriptor it is given, so a shared definition would
62
+ * accumulate the leavings of every page that used it.
63
+ *
64
+ * An unresolvable reference is left in place rather than dropped or guessed.
65
+ * The validator reports it as DANGLING_REF with a did-you-mean; silently
66
+ * removing it would make a missing nav look like a page that never had one.
67
+ */
68
+ export function resolveRefs(elements, defs, seen = new Set()) {
69
+ if (!defs || typeof defs !== "object") return elements;
70
+
71
+ const expand = (node) => {
72
+ if (Array.isArray(node)) return node.map(expand);
73
+ if (!node || typeof node !== "object") return node;
74
+
75
+ if (isRef(node)) {
76
+ const name = node[REF_KEY];
77
+ if (!Object.prototype.hasOwnProperty.call(defs, name)) return node;
78
+ if (seen.has(name)) {
79
+ // A definition that reaches itself. Returning the ref rather
80
+ // than recursing means a cycle is a reported error instead of
81
+ // a stack overflow at render time.
82
+ return node;
83
+ }
84
+ const next = new Set(seen).add(name);
85
+ const base = resolveRefs([defs[name]], defs, next)[0];
86
+
87
+ // Keys written BESIDE the reference override the definition.
88
+ //
89
+ // Without this they were silently dropped — `{ $ref: "nav", id:
90
+ // "topnav" }` expanded to the bare definition and the id went
91
+ // nowhere, which is precisely the declared-but-ignored failure
92
+ // this project spent six stages removing. Having built a new one,
93
+ // the choice was to reject the shape or to honour it; honouring it
94
+ // is what makes a shared fragment reusable at all, since the whole
95
+ // point of sharing a nav is that each page marks a different entry.
96
+ //
97
+ // Shallow, deliberately: a deep merge would make it unclear
98
+ // whether `items` replaces the definition's or extends it. It
99
+ // replaces.
100
+ const { [REF_KEY]: _drop, ...overrides } = node;
101
+ return Object.keys(overrides).length
102
+ ? { ...(base && typeof base === "object" ? base : {}), ...overrides }
103
+ : base;
104
+ }
105
+
106
+ const out = { ...node };
107
+ for (const key of ["children", "items"]) {
108
+ if (out[key] !== undefined) out[key] = expand(out[key]);
109
+ }
110
+ return out;
111
+ };
112
+
113
+ return Array.isArray(elements) ? elements.map(expand) : elements;
114
+ }
115
+
116
+ /** Names defined but never referenced — dead weight in a shared file. */
117
+ export function unusedDefs(elements, defs) {
118
+ if (!defs) return [];
119
+ const used = new Set(collectRefs(elements).map((r) => r.name));
120
+ return Object.keys(defs).filter((n) => !used.has(n));
121
+ }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */