nodality 1.0.221 → 1.1.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 (279) hide show
  1. package/API.md +227 -0
  2. package/bin/nodality.js +64 -0
  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 +131 -1
  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 +1 -1
  48. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  49. package/dist/data-list.esm.js +1 -1
  50. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  51. package/dist/designer.cjs.js +1 -1
  52. package/dist/designer.cjs.js.LICENSE.txt +97 -1
  53. package/dist/designer.esm.js +1 -1
  54. package/dist/designer.esm.js.LICENSE.txt +97 -1
  55. package/dist/element-mapper.cjs.js +1 -1
  56. package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
  57. package/dist/element-mapper.esm.js +1 -1
  58. package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
  59. package/dist/finalresult.esm.js +1 -1
  60. package/dist/finalresult.esm.js.LICENSE.txt +158 -1
  61. package/dist/flex-card.cjs.js +1 -1
  62. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  63. package/dist/flex-card.esm.js +1 -1
  64. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  65. package/dist/flex-grid.cjs.js +1 -1
  66. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  67. package/dist/flex-grid.esm.js +1 -1
  68. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  69. package/dist/flex-row.cjs.js +1 -1
  70. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  71. package/dist/flex-row.esm.js +1 -1
  72. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  73. package/dist/floating-input.cjs.js +1 -1
  74. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  75. package/dist/floating-input.esm.js +1 -1
  76. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  77. package/dist/free.cjs.js +1 -1
  78. package/dist/free.cjs.js.LICENSE.txt +1 -1
  79. package/dist/free.esm.js +1 -1
  80. package/dist/free.esm.js.LICENSE.txt +1 -1
  81. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  82. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  83. package/dist/image-picker.cjs.js +1 -1
  84. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  85. package/dist/image-picker.esm.js +1 -1
  86. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  87. package/dist/image.cjs.js +1 -1
  88. package/dist/image.cjs.js.LICENSE.txt +1 -1
  89. package/dist/image.esm.js +1 -1
  90. package/dist/image.esm.js.LICENSE.txt +1 -1
  91. package/dist/index.cjs.js +1 -1
  92. package/dist/index.cjs.js.LICENSE.txt +158 -1
  93. package/dist/index.d.ts +962 -0
  94. package/dist/index.esm.js +1 -1
  95. package/dist/index.esm.js.LICENSE.txt +158 -1
  96. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  97. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  98. package/dist/link-getter.cjs.js +1 -1
  99. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  100. package/dist/link-getter.esm.js +1 -1
  101. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  102. package/dist/link.cjs.js +1 -1
  103. package/dist/link.cjs.js.LICENSE.txt +1 -1
  104. package/dist/link.esm.js +1 -1
  105. package/dist/link.esm.js.LICENSE.txt +1 -1
  106. package/dist/meta-adder.cjs.js +1 -1
  107. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  108. package/dist/meta-adder.esm.js +1 -1
  109. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  110. package/dist/modal-2025.cjs.js +1 -1
  111. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  112. package/dist/modal-2025.esm.js +1 -1
  113. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  114. package/dist/multiswitcher.cjs.js +1 -1
  115. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  116. package/dist/multiswitcher.esm.js +1 -1
  117. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  118. package/dist/new-nav-bar.cjs.js +1 -1
  119. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  120. package/dist/new-nav-bar.esm.js +1 -1
  121. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  122. package/dist/picker.cjs.js +1 -1
  123. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  124. package/dist/picker.esm.js +1 -1
  125. package/dist/picker.esm.js.LICENSE.txt +1 -1
  126. package/dist/progress.cjs.js +1 -1
  127. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  128. package/dist/progress.esm.js +1 -1
  129. package/dist/progress.esm.js.LICENSE.txt +1 -1
  130. package/dist/radio.cjs.js +1 -1
  131. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  132. package/dist/radio.esm.js +1 -1
  133. package/dist/radio.esm.js.LICENSE.txt +1 -1
  134. package/dist/range.cjs.js +1 -1
  135. package/dist/range.cjs.js.LICENSE.txt +1 -1
  136. package/dist/range.esm.js +1 -1
  137. package/dist/range.esm.js.LICENSE.txt +1 -1
  138. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  139. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  140. package/dist/side-bar.cjs.js +1 -1
  141. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  142. package/dist/side-bar.esm.js +1 -1
  143. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  144. package/dist/side-nav-bar.cjs.js +1 -1
  145. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  146. package/dist/side-nav-bar.esm.js +1 -1
  147. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  148. package/dist/simple-bar.cjs.js +1 -1
  149. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  150. package/dist/simple-bar.esm.js +1 -1
  151. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  152. package/dist/slider-2025.cjs.js +1 -1
  153. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  154. package/dist/slider-2025.esm.js +1 -1
  155. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  156. package/dist/spacer.cjs.js +1 -1
  157. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  158. package/dist/spacer.esm.js +1 -1
  159. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  160. package/dist/stack.cjs.js +1 -1
  161. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  162. package/dist/stack.esm.js +1 -1
  163. package/dist/stack.esm.js.LICENSE.txt +1 -1
  164. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  165. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  166. package/dist/table.cjs.js +1 -1
  167. package/dist/table.cjs.js.LICENSE.txt +1 -1
  168. package/dist/table.esm.js +1 -1
  169. package/dist/table.esm.js.LICENSE.txt +1 -1
  170. package/dist/text-field.cjs.js +1 -1
  171. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  172. package/dist/text-field.esm.js +1 -1
  173. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  174. package/dist/text.cjs.js +1 -1
  175. package/dist/text.cjs.js.LICENSE.txt +1 -1
  176. package/dist/text.esm.js +1 -1
  177. package/dist/text.esm.js.LICENSE.txt +1 -1
  178. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  179. package/dist/theme.esm.js.LICENSE.txt +1 -1
  180. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  181. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  182. package/dist/ulist.cjs.js +1 -1
  183. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  184. package/dist/ulist.esm.js +1 -1
  185. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  186. package/dist/video.cjs.js +1 -1
  187. package/dist/video.cjs.js.LICENSE.txt +1 -1
  188. package/dist/video.esm.js +1 -1
  189. package/dist/video.esm.js.LICENSE.txt +1 -1
  190. package/dist/wrap.cjs.js +1 -1
  191. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  192. package/dist/wrap.esm.js +1 -1
  193. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  194. package/dist/zoom-card.cjs.js +1 -1
  195. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  196. package/dist/zoom-card.esm.js +1 -1
  197. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  198. package/examples/custom-raster-op.js +174 -0
  199. package/layout/animator.js +30 -2
  200. package/layout/audio.js +1 -1
  201. package/layout/audionew.js +1 -1
  202. package/layout/base.js +1 -1
  203. package/layout/beta-desktop-bar.js +1 -1
  204. package/layout/beta-mobile-bar.js +3 -2
  205. package/layout/button.js +1 -1
  206. package/layout/center.js +1 -1
  207. package/layout/checkbox.js +2 -2
  208. package/layout/circle.js +3 -2
  209. package/layout/code.js +3 -2
  210. package/layout/container.js +3 -4
  211. package/layout/dropdown-2025.js +1 -1
  212. package/layout/flex-card.js +1 -1
  213. package/layout/flex-grid.js +1 -1
  214. package/layout/flex-row.js +3 -2
  215. package/layout/form-components/custom.js +1 -1
  216. package/layout/form-components/data-list.js +2 -2
  217. package/layout/form-components/floating-input.js +2 -2
  218. package/layout/form-components/form-all.js +2 -2
  219. package/layout/form-components/form.js +1 -1
  220. package/layout/form-components/image-picker.js +1 -1
  221. package/layout/form-components/picker.js +2 -2
  222. package/layout/form-components/radio.js +2 -2
  223. package/layout/form-components/radiogroup.js +2 -2
  224. package/layout/form-components/range.js +2 -2
  225. package/layout/free.js +3 -4
  226. package/layout/grid-switcher.js +1 -1
  227. package/layout/grid.js +1 -1
  228. package/layout/horizontal-scroller.js +1 -1
  229. package/layout/image.js +1 -1
  230. package/layout/index.js +1 -1
  231. package/layout/link.js +5 -5
  232. package/layout/list.js +3 -4
  233. package/layout/meta-adder.js +1 -1
  234. package/layout/modal-2025.js +1 -1
  235. package/layout/morph.js +1003 -0
  236. package/layout/multiswitcher.js +11 -3
  237. package/layout/nav-bar.js +2 -3
  238. package/layout/nav-factor/custom-div.js +2 -2
  239. package/layout/new-nav-bar.js +11 -4
  240. package/layout/polygon.js +3 -4
  241. package/layout/prerender-site.js +1 -1
  242. package/layout/prerender.js +1 -1
  243. package/layout/progress.js +1 -1
  244. package/layout/row.js +1 -1
  245. package/layout/scroll-video.js +1 -1
  246. package/layout/side-bar.js +1 -1
  247. package/layout/side-nav-bar.js +1 -1
  248. package/layout/simple-bar.js +1 -1
  249. package/layout/slider-2025.js +1 -1
  250. package/layout/spacer.js +1 -1
  251. package/layout/stack.js +1 -1
  252. package/layout/svg.js +3 -4
  253. package/layout/switcher.js +1 -1
  254. package/layout/table.js +1 -1
  255. package/layout/text-field.js +1 -1
  256. package/layout/text.js +5 -5
  257. package/layout/ulist.js +1 -1
  258. package/layout/video.js +1 -1
  259. package/layout/wrap.js +1 -1
  260. package/layout/zoom-card.js +1 -1
  261. package/lib/card-getter.js +1 -1
  262. package/lib/codegen.js +62 -0
  263. package/lib/data.js +37 -1
  264. package/lib/designer.js +18 -1
  265. package/lib/element-mapper.js +351 -247
  266. package/lib/keyframe-animation.js +1 -1
  267. package/lib/link-getter.js +1 -1
  268. package/lib/morph-node.js +424 -0
  269. package/lib/raster-inspect.js +409 -0
  270. package/lib/raster-ops.js +1980 -81
  271. package/lib/raster-presets.js +329 -0
  272. package/lib/scroll-video.js +1 -1
  273. package/lib/seo.js +28 -1
  274. package/lib/stacker.js +1 -1
  275. package/lib/suggest.js +66 -0
  276. package/lib/theme.js +1 -1
  277. package/lib/transform-anim.js +1 -1
  278. package/lib/transition.js +205 -0
  279. package/package.json +20 -7
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.221
2
+ * nodality v1.1.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.221
2
+ * nodality v1.1.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -0,0 +1,424 @@
1
+ /*!
2
+ * nodality v1.1.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /*!
8
+ * morph-node.js — `{ op: "morph" }`, the (E,N) form of a transition.
9
+ *
10
+ * Everything else in N describes how an element LOOKS. A morph node
11
+ * describes which element BECOMES which, and on what interaction:
12
+ *
13
+ * { op: "morph", from: "topnav", to: ["work", "about", "contact"],
14
+ * effect: "t-vhs", duration: 900, back: true }
15
+ *
16
+ * Before this, a transition was the one thing the two arrays could not
17
+ * express. A page wanting a morph had to capture both sides itself,
18
+ * measure two rects, call applyRasterPipeline with a `transition` block,
19
+ * drive `t` through a timeline, and wire the clicks — a few hundred lines
20
+ * sitting *around* the arrays rather than in them.
21
+ *
22
+ * What this owns, so a caller does not have to:
23
+ *
24
+ * - the destinations start hidden, so only the source is on screen
25
+ * - link i in the source maps to destination i
26
+ * - both sides are captured as stills and travel one converging box
27
+ * - `back: true` wires the destination's own button to return
28
+ *
29
+ * Runs AFTER Des has mounted, because it needs rendered geometry: rects
30
+ * come from real layout, not from the descriptors.
31
+ */
32
+
33
+ import { applyRasterPipeline, activeRasterPipelines } from "./raster-ops.js";
34
+ import { preset } from "./raster-presets.js";
35
+ import { progressTimeline } from "./transition.js";
36
+
37
+ /** An element's box in its host's coordinates — what a transition wants. */
38
+ const rectIn = (host, el) => {
39
+ const h = host.getBoundingClientRect();
40
+ const r = el.getBoundingClientRect();
41
+ return { x: r.x - h.x, y: r.y - h.y, w: r.width, h: r.height };
42
+ };
43
+
44
+ /**
45
+ * Freeze a subtree into an Image.
46
+ *
47
+ * Two things a naive foreignObject capture gets wrong, both of which show
48
+ * up as a jump at the handover:
49
+ *
50
+ * - RESOLUTION. Asking for w x h rasterises at CSS density, so on a 2x
51
+ * display the still is half the density of the box it is drawn into
52
+ * and reads soft next to real text. The viewBox stays in CSS units;
53
+ * only the raster grid grows.
54
+ * - VIEWPORT UNITS. Nodality sizes text with calc(1.375rem + 3.525vw).
55
+ * Inside a foreignObject, vw resolves against the SVG's OWN viewport,
56
+ * not the page — so the same heading renders ~33px in a 300px capture
57
+ * and ~89px on a wide window. Computed type is copied onto the clone
58
+ * so the still matches the layout it replaces.
59
+ */
60
+ async function capture(el, w, h) {
61
+ const clone = el.cloneNode(true);
62
+ clone.style.position = "static";
63
+ clone.style.left = clone.style.top = "0";
64
+ clone.style.margin = "0";
65
+ // The element being captured is kept visibility:hidden so it cannot
66
+ // flash before the pipeline owns it (see goTo). It still lays out, so
67
+ // rects and serialisation are correct — but the clone has to paint.
68
+ clone.style.visibility = "visible";
69
+
70
+ const src = [el, ...el.querySelectorAll("*")];
71
+ const dst = [clone, ...clone.querySelectorAll("*")];
72
+ for (let i = 0; i < src.length; i++) {
73
+ const cs = getComputedStyle(src[i]);
74
+ dst[i].style.fontSize = cs.fontSize;
75
+ dst[i].style.lineHeight = cs.lineHeight;
76
+ dst[i].style.fontWeight = cs.fontWeight;
77
+ dst[i].style.fontFamily = cs.fontFamily;
78
+ dst[i].style.letterSpacing = cs.letterSpacing;
79
+ // BOX metrics too, not just type. `width: min(560px, 92vw)`
80
+ // re-resolves inside the foreignObject against the SVG's viewport
81
+ // rather than the page, so a card measured at 307px lays out at
82
+ // 282px in the capture — and that narrower layout is then
83
+ // stretched back into the 307px box. The BOX matches, so it
84
+ // measures clean; the CONTENT inside is visibly scaled up, and
85
+ // swapping it for the real element at t=1 is a pop.
86
+ dst[i].style.width = cs.width;
87
+ dst[i].style.height = cs.height;
88
+ dst[i].style.padding = cs.padding;
89
+ dst[i].style.boxSizing = "content-box";
90
+ }
91
+
92
+ const R = Math.max(1, Math.min(
93
+ (typeof window !== "undefined" && window.devicePixelRatio) || 1, 3));
94
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w * R}" ` +
95
+ `height="${h * R}" viewBox="0 0 ${w} ${h}">` +
96
+ `<foreignObject width="100%" height="100%">` +
97
+ `<div xmlns="http://www.w3.org/1999/xhtml">` +
98
+ new XMLSerializer().serializeToString(clone) +
99
+ `</div></foreignObject></svg>`;
100
+ const img = new Image();
101
+ await new Promise((res, rej) => {
102
+ img.onload = res;
103
+ img.onerror = rej;
104
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
105
+ });
106
+ return img;
107
+ }
108
+
109
+ const chainFor = (effect) => {
110
+ if (!effect) return [];
111
+ if (Array.isArray(effect)) return effect; // an inline node array
112
+ try { return preset(effect) || []; } catch (e) { return []; }
113
+ };
114
+
115
+ /**
116
+ * @param {Element} mount the container Des rendered into
117
+ * @param {object[]} elements the E array, in order
118
+ * @param {object[]} nodes the N array
119
+ * @returns {object[]} one handle per morph node, for tests and teardown
120
+ */
121
+ function applyMorphNodes(mount, elements, nodes) {
122
+ if (!mount || typeof document === "undefined") return [];
123
+ const morphs = (nodes || []).filter((n) => n && n.op === "morph");
124
+ if (!morphs.length) return [];
125
+
126
+ // Pair descriptors with rendered nodes BY POSITION. Some components
127
+ // carry their `id` through to the DOM (wrap does) and some do not
128
+ // (protoNav drops it), so an id lookup alone silently misses half the
129
+ // page. Position is what Des actually guarantees.
130
+ const rendered = [...mount.children];
131
+ const resolve = (id) => {
132
+ const byId = document.getElementById(id);
133
+ if (byId && mount.contains(byId)) return byId;
134
+ const i = (elements || []).findIndex((e) => e && e.id === id);
135
+ return i >= 0 ? rendered[i] : null;
136
+ };
137
+
138
+ return morphs.map((m) => setUpMorph(mount, m, resolve)).filter(Boolean);
139
+ }
140
+
141
+ function setUpMorph(mount, m, resolve) {
142
+ const fromEl = resolve(m.from);
143
+
144
+ // `to` takes two forms, and the object form is the one that survives
145
+ // a real navbar:
146
+ //
147
+ // ["work", "about"] by POSITION — link i goes to i
148
+ // { Work: "work", About: "about" } by LABEL — link text decides
149
+ //
150
+ // Position looks tidier and is wrong for anything but a toy nav. A
151
+ // responsive bar renders a DIFFERENT set of links per breakpoint
152
+ // (About/Services/Contact wide, About/About/Contact collapsed), so
153
+ // index i is not the same destination at two window sizes. And a nav
154
+ // that repeats a label sends two links to one arbitrary place. Labels
155
+ // are what the user is actually reading and clicking.
156
+ const byLabel = m.to && !Array.isArray(m.to) && typeof m.to === "object"
157
+ ? m.to : null;
158
+ const toIds = byLabel
159
+ ? [...new Set(Object.values(m.to))]
160
+ : (Array.isArray(m.to) ? m.to : [m.to]);
161
+ const toEls = toIds.map(resolve);
162
+ if (!fromEl || !toEls.some(Boolean)) {
163
+ console.warn("[nodality] morph: could not resolve", m.from, "->", toIds);
164
+ return null;
165
+ }
166
+ const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
167
+ const labelToIndex = new Map();
168
+ if (byLabel) {
169
+ for (const [label, id] of Object.entries(m.to)) {
170
+ const i = toIds.indexOf(id);
171
+ if (i >= 0) labelToIndex.set(norm(label), i);
172
+ }
173
+ }
174
+
175
+ // A positioned stage, standing where the source stood. The source and
176
+ // the destinations are siblings inside it rather than in page flow,
177
+ // because a morph interpolates a BOX and both ends need a common
178
+ // coordinate space to be measured in.
179
+ const stage = document.createElement("div");
180
+ stage.className = "nod-morph";
181
+ stage.style.cssText = "position:relative;";
182
+ mount.insertBefore(stage, fromEl);
183
+
184
+ const rhost = document.createElement("div");
185
+ rhost.className = "nod-morph-host";
186
+ rhost.style.cssText = "position:absolute;inset:0;";
187
+ // The source stays OUTSIDE the raster host on purpose: the pipeline
188
+ // suppresses its host's ink while t is in (0,1), and the source must
189
+ // stay clickable and selectable at rest.
190
+ const live = document.createElement("div");
191
+ live.className = "nod-morph-live";
192
+ live.style.cssText = "position:absolute;inset:0;";
193
+ stage.appendChild(rhost);
194
+ stage.appendChild(live);
195
+
196
+ live.appendChild(fromEl);
197
+ fromEl.style.position = "absolute";
198
+ fromEl.style.left = fromEl.style.top = "0";
199
+
200
+ // Destinations start OFF the page. Without this every subview renders
201
+ // stacked under the nav, which is the whole "why is everything
202
+ // visible at once" problem.
203
+ const templates = toEls.map((el) => {
204
+ if (!el) return null;
205
+ el.remove();
206
+ return el;
207
+ });
208
+
209
+ const sizeStage = (h) => {
210
+ stage.style.minHeight =
211
+ Math.max(fromEl.getBoundingClientRect().height, h || 0) + 24 + "px";
212
+ };
213
+ sizeStage(0);
214
+
215
+ let pipe = null;
216
+ let tl = null;
217
+ let active = -1;
218
+
219
+ const goTo = async (i) => {
220
+ const dest = templates[i];
221
+ if (!dest) return;
222
+ active = i;
223
+
224
+ for (const p of activeRasterPipelines()) {
225
+ if (stage.contains(p.canvas)) p.destroy();
226
+ }
227
+ rhost.innerHTML = "";
228
+ rhost.appendChild(dest);
229
+ // Hidden from the moment it enters the document. Between insertion
230
+ // and applyRasterPipeline there are two awaited captures, and a
231
+ // destination that is merely in the DOM paints for those frames —
232
+ // which is the destination flashing before the morph runs.
233
+ // visibility (not display) so it still lays out and can be measured.
234
+ dest.style.visibility = "hidden";
235
+
236
+ // WAIT FOR IMAGES before measuring anything.
237
+ //
238
+ // An <img> has no intrinsic box until it decodes — even from a
239
+ // data: URL, which is async too. Measure before that and the
240
+ // destination's rect is its pre-image layout, so the shader draws
241
+ // the capture into a box that does not match where the real card
242
+ // ends up. The handover then pops between two different sizes,
243
+ // which is the flash at the end of the transition.
244
+ const imgs = [...dest.querySelectorAll("img")];
245
+ if (imgs.length) {
246
+ await Promise.all(imgs.map((im) => {
247
+ if (im.complete && im.naturalWidth) return null;
248
+ if (typeof im.decode === "function") return im.decode().catch(() => {});
249
+ return new Promise((res) => {
250
+ im.addEventListener("load", res, { once: true });
251
+ im.addEventListener("error", res, { once: true });
252
+ });
253
+ }));
254
+ // One more frame so layout reflects the decoded intrinsic size.
255
+ await new Promise((r) => requestAnimationFrame(r));
256
+ }
257
+
258
+ const fromRect = rectIn(rhost, fromEl);
259
+ Object.assign(dest.style, { position: "absolute", left: "0",
260
+ top: Math.round(fromRect.h + 20) + "px", boxSizing: "border-box" });
261
+ const toRect = rectIn(rhost, dest);
262
+ sizeStage(toRect.y + toRect.h);
263
+
264
+ const [oldImage, newImage] = await Promise.all([
265
+ capture(fromEl, fromRect.w, fromRect.h),
266
+ capture(dest, toRect.w, toRect.h),
267
+ ]);
268
+
269
+ pipe = applyRasterPipeline(rhost, chainFor(m.effect), {
270
+ transition: {
271
+ oldImage, newImage,
272
+ oldRect: fromRect, oldTo: toRect,
273
+ newRect: toRect, newFrom: fromRect,
274
+ // The morph owns the screen strictly BETWEEN its ends: the
275
+ // real source presents at t=0, the real destination at t=1.
276
+ standDownAtStart: true,
277
+ // Opt back into the HTML-in-Canvas backend. Off by default
278
+ // for a morph: it moves the content INTO the canvas, where
279
+ // it becomes fallback content and stops being hit-testable.
280
+ live: m.live === true,
281
+ // Per-pixel choice rather than a uniform mix. A crossfade
282
+ // shows 50% of each at the midpoint, which reads as two
283
+ // states at once rather than one becoming the other.
284
+ fade: m.fade || "morph",
285
+ },
286
+ });
287
+
288
+ if (tl) tl.destroy();
289
+ tl = progressTimeline(pipe, { duration: m.duration != null ? m.duration : 900 });
290
+ tl.set(0);
291
+
292
+ if (m.back !== false) {
293
+ const btn = dest.querySelector("button");
294
+ if (btn && !btn.dataset.nodMorphBack) {
295
+ btn.dataset.nodMorphBack = "1";
296
+ btn.addEventListener("click", () => { if (tl) tl.to(0); });
297
+ }
298
+ }
299
+ tl.to(1);
300
+ };
301
+
302
+ // Link i in the source goes to destination i. By POSITION, not label:
303
+ // a prototype nav can render two links reading the same thing, and
304
+ // matching on text would send both to one destination.
305
+ /**
306
+ * What in the source starts the morph.
307
+ *
308
+ * Anchors take PRIORITY, and that ordering is load-bearing rather
309
+ * than stylistic: a navbar's own hamburger is a <button>, and it
310
+ * DISCLOSES the menu — it must never navigate. So buttons are only
311
+ * considered when the source contains no links at all, which is the
312
+ * case a plain call-to-action button is. If the source is itself the
313
+ * control (a lone button), the source is the trigger.
314
+ */
315
+ const triggersOf = () => {
316
+ const links = [...fromEl.querySelectorAll("a")];
317
+ if (links.length) return links;
318
+ const btns = [...fromEl.querySelectorAll("button, [role=button]")];
319
+ if (btns.length) return btns;
320
+ return [fromEl];
321
+ };
322
+
323
+ const wire = (a) => {
324
+ if (a.dataset.nodMorphWired) return;
325
+ const triggers = triggersOf();
326
+ // One destination means there is nothing to disambiguate: any
327
+ // trigger goes there, whatever it is labelled. Only a fan-out
328
+ // needs a label map or an index.
329
+ const idx = toIds.length === 1 ? 0
330
+ : byLabel ? labelToIndex.get(norm(a.textContent))
331
+ : triggers.indexOf(a);
332
+ if (idx == null || idx < 0 || !templates[idx]) return;
333
+ a.dataset.nodMorphWired = "1";
334
+ a.dataset.nodMorphTo = toIds[idx];
335
+ a.addEventListener("click", (e) => {
336
+ // Nothing resolved -> the link keeps its own behaviour, which
337
+ // on an ordinary page is real navigation.
338
+ if (!templates[idx]) return;
339
+ e.preventDefault();
340
+ goTo(idx);
341
+ });
342
+ };
343
+ const wireAll = () => triggersOf().forEach(wire);
344
+ wireAll();
345
+
346
+ // A responsive nav REPLACES its links when it crosses a breakpoint —
347
+ // the Switcher swaps the whole view — so anything wired once is gone
348
+ // after a resize. Re-wire whatever appears; `nodMorphWired` keeps it
349
+ // idempotent.
350
+ if (typeof MutationObserver !== "undefined") {
351
+ new MutationObserver(wireAll).observe(fromEl,
352
+ { childList: true, subtree: true });
353
+ }
354
+
355
+ // Only the source is on screen until something is chosen.
356
+ const frame = () => {
357
+ if (!stage.isConnected) return;
358
+ // Who presents: the real source at t=0, the canvas in between, the
359
+ // real destination at t=1. Driving BOTH ends off progress means
360
+ // there is no moment where two of them are on screen together, and
361
+ // none where the destination appears before its morph has run.
362
+ const t = pipe ? pipe.progress : 0;
363
+ const cv = rhost.querySelector("canvas");
364
+ // Is the canvas ACTUALLY presenting right now? Not "should it be" —
365
+ // the pipeline owns that flag and re-asserts it from its own rAF
366
+ // loop, which is a different loop from this one.
367
+ //
368
+ // Both endpoints used to be computed from `t` alone, in parallel
369
+ // with the pipeline's decision. Whichever loop ran first that
370
+ // frame won, and when the pipeline hid the canvas before this loop
371
+ // revealed the DOM there was a single frame with NEITHER on
372
+ // screen — a flash at the end of the transition, in both
373
+ // directions.
374
+ //
375
+ // Keying off the canvas's real state makes the two states OVERLAP
376
+ // instead of gapping. Overlap is invisible: the shader's last
377
+ // frame is the destination and its first is the source, so a frame
378
+ // showing both is identical to a frame showing either. A frame
379
+ // showing neither is the background.
380
+ const canvasUp = !!cv && (cv.style.visibility || "visible") === "visible";
381
+ // Hand over only once the canvas has ACTUALLY stood down, and let
382
+ // `t` decide which end takes over. Both conditions are needed:
383
+ //
384
+ // canvas still up -> it is presenting; both DOM ends stay
385
+ // hidden, whatever t says
386
+ // canvas down -> the pipeline has finished its handover
387
+ // pass, which is also where it restores the
388
+ // host's ink. Revealing before that shows a
389
+ // real element whose colours are still
390
+ // suppressed — a transparent box for one
391
+ // frame, which is the flash.
392
+ //
393
+ // Gating on `t` alone raced the pipeline's own loop; gating on the
394
+ // canvas alone showed BOTH ends at t=1, because at that point the
395
+ // canvas is down and nothing distinguished the two.
396
+ const handover = !pipe || !canvasUp;
397
+ const wantLive = handover && t <= 0 ? "" : "none";
398
+ if (live.style.display !== wantLive) live.style.display = wantLive;
399
+ const dest = rhost.firstElementChild;
400
+ if (dest && dest.style) {
401
+ const want = pipe && handover && t >= 1 ? "" : "hidden";
402
+ if (dest.style.visibility !== want) dest.style.visibility = want;
403
+ }
404
+ requestAnimationFrame(frame);
405
+ };
406
+ requestAnimationFrame(frame);
407
+
408
+ return {
409
+ stage, from: fromEl, destinations: templates,
410
+ get progress() { return pipe ? pipe.progress : 0; },
411
+ get active() { return active; },
412
+ goTo,
413
+ back() { if (tl) tl.to(0); },
414
+ destroy() {
415
+ if (tl) tl.destroy();
416
+ for (const p of activeRasterPipelines()) {
417
+ if (stage.contains(p.canvas)) p.destroy();
418
+ }
419
+ stage.remove();
420
+ },
421
+ };
422
+ }
423
+
424
+ export { applyMorphNodes };