nodality 1.1.11 → 1.1.13

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 (232) hide show
  1. package/dist/animator.cjs.js +1 -1
  2. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  3. package/dist/animator.esm.js.LICENSE.txt +1 -1
  4. package/dist/audionew.cjs.js +1 -1
  5. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  6. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  7. package/dist/base.cjs.js +1 -1
  8. package/dist/base.cjs.js.LICENSE.txt +1 -1
  9. package/dist/base.esm.js.LICENSE.txt +1 -1
  10. package/dist/beta-desktop-bar.cjs.js +1 -1
  11. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  12. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  13. package/dist/beta-mobile-bar.cjs.js +1 -1
  14. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  15. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  16. package/dist/bundle.umd.js +1 -1
  17. package/dist/bundle.umd.js.LICENSE.txt +1 -1
  18. package/dist/button.cjs.js +1 -1
  19. package/dist/button.cjs.js.LICENSE.txt +1 -1
  20. package/dist/button.esm.js.LICENSE.txt +1 -1
  21. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  22. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  23. package/dist/center.cjs.js +1 -1
  24. package/dist/center.cjs.js.LICENSE.txt +1 -1
  25. package/dist/center.esm.js.LICENSE.txt +1 -1
  26. package/dist/checkbox.cjs.js +1 -1
  27. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  28. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  29. package/dist/code.cjs.js +1 -1
  30. package/dist/code.cjs.js.LICENSE.txt +1 -1
  31. package/dist/code.esm.js.LICENSE.txt +1 -1
  32. package/dist/container.cjs.js +1 -1
  33. package/dist/container.cjs.js.LICENSE.txt +1 -1
  34. package/dist/container.esm.js.LICENSE.txt +1 -1
  35. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  36. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  37. package/dist/designer.cjs.js +1 -1
  38. package/dist/designer.cjs.js.LICENSE.txt +1 -1
  39. package/dist/designer.esm.js +1 -1
  40. package/dist/designer.esm.js.LICENSE.txt +1 -1
  41. package/dist/element-mapper.cjs.js +1 -1
  42. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  43. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  44. package/dist/finalresult.esm.js +1 -1
  45. package/dist/finalresult.esm.js.LICENSE.txt +1 -1
  46. package/dist/flex-card.cjs.js +1 -1
  47. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  48. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  49. package/dist/flex-grid.cjs.js +1 -1
  50. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  51. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  52. package/dist/flex-row.cjs.js +1 -1
  53. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  54. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  55. package/dist/floating-input.cjs.js +1 -1
  56. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  57. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  58. package/dist/free.cjs.js +1 -1
  59. package/dist/free.cjs.js.LICENSE.txt +1 -1
  60. package/dist/free.esm.js.LICENSE.txt +1 -1
  61. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  62. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  63. package/dist/image-picker.cjs.js +1 -1
  64. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  65. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  66. package/dist/image.cjs.js +1 -1
  67. package/dist/image.cjs.js.LICENSE.txt +1 -1
  68. package/dist/image.esm.js.LICENSE.txt +1 -1
  69. package/dist/index.cjs.js +1 -1
  70. package/dist/index.cjs.js.LICENSE.txt +1 -1
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.esm.js +1 -1
  73. package/dist/index.esm.js.LICENSE.txt +1 -1
  74. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  75. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  76. package/dist/link-getter.cjs.js +1 -1
  77. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  78. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  79. package/dist/link.cjs.js +1 -1
  80. package/dist/link.cjs.js.LICENSE.txt +1 -1
  81. package/dist/link.esm.js.LICENSE.txt +1 -1
  82. package/dist/meta-adder.cjs.js +1 -1
  83. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  84. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  85. package/dist/modal-2025.cjs.js +1 -1
  86. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  87. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  88. package/dist/multiswitcher.cjs.js +1 -1
  89. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  90. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  91. package/dist/new-nav-bar.cjs.js +1 -1
  92. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  93. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  94. package/dist/picker.cjs.js +1 -1
  95. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  96. package/dist/picker.esm.js.LICENSE.txt +1 -1
  97. package/dist/progress.cjs.js +1 -1
  98. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  99. package/dist/progress.esm.js.LICENSE.txt +1 -1
  100. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  101. package/dist/radio.esm.js.LICENSE.txt +1 -1
  102. package/dist/range.cjs.js.LICENSE.txt +1 -1
  103. package/dist/range.esm.js.LICENSE.txt +1 -1
  104. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  105. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  106. package/dist/side-bar.cjs.js +1 -1
  107. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  108. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  109. package/dist/side-nav-bar.cjs.js +1 -1
  110. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  111. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  112. package/dist/simple-bar.cjs.js +1 -1
  113. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  114. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  115. package/dist/slider-2025.cjs.js +1 -1
  116. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  117. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  118. package/dist/spacer.cjs.js +1 -1
  119. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  120. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  121. package/dist/stack.cjs.js +1 -1
  122. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  123. package/dist/stack.esm.js.LICENSE.txt +1 -1
  124. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  125. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  126. package/dist/table.cjs.js +1 -1
  127. package/dist/table.cjs.js.LICENSE.txt +1 -1
  128. package/dist/table.esm.js.LICENSE.txt +1 -1
  129. package/dist/text-field.cjs.js +1 -1
  130. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  131. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  132. package/dist/text.cjs.js +1 -1
  133. package/dist/text.cjs.js.LICENSE.txt +1 -1
  134. package/dist/text.esm.js.LICENSE.txt +1 -1
  135. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  136. package/dist/theme.esm.js.LICENSE.txt +1 -1
  137. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  138. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  139. package/dist/ulist.cjs.js +1 -1
  140. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  141. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  142. package/dist/video.cjs.js +1 -1
  143. package/dist/video.cjs.js.LICENSE.txt +1 -1
  144. package/dist/video.esm.js.LICENSE.txt +1 -1
  145. package/dist/wrap.cjs.js +1 -1
  146. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  147. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  148. package/dist/zoom-card.cjs.js +1 -1
  149. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  150. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  151. package/layout/animator.js +1 -1
  152. package/layout/audio.js +1 -1
  153. package/layout/audionew.js +1 -1
  154. package/layout/base.js +1 -1
  155. package/layout/beta-desktop-bar.js +1 -1
  156. package/layout/beta-mobile-bar.js +1 -1
  157. package/layout/button.js +1 -1
  158. package/layout/center.js +1 -1
  159. package/layout/checkbox.js +1 -1
  160. package/layout/circle.js +1 -1
  161. package/layout/code.js +1 -1
  162. package/layout/container.js +1 -1
  163. package/layout/dropdown-2025.js +1 -1
  164. package/layout/flex-card.js +1 -1
  165. package/layout/flex-grid.js +1 -1
  166. package/layout/flex-row.js +1 -1
  167. package/layout/form-components/custom.js +1 -1
  168. package/layout/form-components/data-list.js +1 -1
  169. package/layout/form-components/floating-input.js +1 -1
  170. package/layout/form-components/form-all.js +1 -1
  171. package/layout/form-components/form.js +1 -1
  172. package/layout/form-components/image-picker.js +1 -1
  173. package/layout/form-components/picker.js +1 -1
  174. package/layout/form-components/radio.js +1 -1
  175. package/layout/form-components/radiogroup.js +1 -1
  176. package/layout/form-components/range.js +1 -1
  177. package/layout/free.js +1 -1
  178. package/layout/grid-switcher.js +1 -1
  179. package/layout/grid.js +1 -1
  180. package/layout/horizontal-scroller.js +1 -1
  181. package/layout/image.js +1 -1
  182. package/layout/index.js +1 -1
  183. package/layout/link.js +1 -1
  184. package/layout/list.js +1 -1
  185. package/layout/meta-adder.js +1 -1
  186. package/layout/modal-2025.js +1 -1
  187. package/layout/morph.js +1 -1
  188. package/layout/multiswitcher.js +1 -1
  189. package/layout/nav-bar.js +1 -1
  190. package/layout/nav-factor/custom-div.js +1 -1
  191. package/layout/new-nav-bar.js +1 -1
  192. package/layout/polygon.js +1 -1
  193. package/layout/prerender-site.js +1 -1
  194. package/layout/prerender.js +1 -1
  195. package/layout/progress.js +1 -1
  196. package/layout/row.js +1 -1
  197. package/layout/scroll-video.js +1 -1
  198. package/layout/side-bar.js +1 -1
  199. package/layout/side-nav-bar.js +1 -1
  200. package/layout/simple-bar.js +1 -1
  201. package/layout/slider-2025.js +1 -1
  202. package/layout/spacer.js +1 -1
  203. package/layout/stack.js +1 -1
  204. package/layout/svg.js +1 -1
  205. package/layout/switcher.js +1 -1
  206. package/layout/table.js +1 -1
  207. package/layout/text-field.js +1 -1
  208. package/layout/text.js +1 -1
  209. package/layout/ulist.js +1 -1
  210. package/layout/video.js +1 -1
  211. package/layout/wrap.js +1 -1
  212. package/layout/zoom-card.js +1 -1
  213. package/lib/card-getter.js +1 -1
  214. package/lib/codegen.js +1 -1
  215. package/lib/data.js +1 -1
  216. package/lib/designer.js +1 -1
  217. package/lib/element-mapper.js +1 -1
  218. package/lib/keyframe-animation.js +1 -1
  219. package/lib/link-getter.js +1 -1
  220. package/lib/morph-node.js +487 -116
  221. package/lib/raster-inspect.js +1 -1
  222. package/lib/raster-ops.js +10 -2
  223. package/lib/raster-presets.js +1 -1
  224. package/lib/scroll-video.js +1 -1
  225. package/lib/seo.js +1 -1
  226. package/lib/stacker.js +1 -1
  227. package/lib/suggest.js +1 -1
  228. package/lib/theme.js +1 -1
  229. package/lib/transform-anim.js +1 -1
  230. package/lib/transition.js +1 -1
  231. package/lib/validate-nodes.js +1 -1
  232. package/package.json +1 -1
package/lib/morph-node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.1.11
2
+ * nodality v1.1.13
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -121,6 +121,80 @@ const chainFor = (effect) => {
121
121
  try { return preset(effect) || []; } catch (e) { return []; }
122
122
  };
123
123
 
124
+ const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
125
+
126
+ /**
127
+ * A morph node, in one shape: a list of EDGES.
128
+ *
129
+ * A node is written either as a single hop —
130
+ *
131
+ * { op: "morph", from: "topnav", to: { About: "about" } }
132
+ *
133
+ * — or as a chain, which is the same thing with more than one edge:
134
+ *
135
+ * { op: "morph", effect: "t-vhs", duration: 900, back: true,
136
+ * chain: [ { from: "topnav", to: { About: "about" } },
137
+ * { from: "about", to: { Detail: "about-detail" } } ] }
138
+ *
139
+ * Both arrive here as a list, so there is exactly one code path below and
140
+ * the single hop is not a special case of anything. `chain` entries are
141
+ * EDGES rather than keyframes: entry two does not happen after entry one,
142
+ * it is reachable FROM the state entry one lands on. That is why they
143
+ * carry no ordinal — array order is already order, and a number would
144
+ * claim a sequence the graph does not have.
145
+ *
146
+ * Per-edge options fall back to the node, so the common settings are
147
+ * written once and an edge overrides only what differs.
148
+ */
149
+ const normalizeEdges = (m) => {
150
+ const raw = Array.isArray(m.chain) && m.chain.length
151
+ ? m.chain
152
+ : [{ from: m.from, to: m.to }];
153
+
154
+ return raw.map((e) => {
155
+ const opt = (key, fallback) =>
156
+ (e[key] !== undefined ? e[key]
157
+ : m[key] !== undefined ? m[key] : fallback);
158
+
159
+ // `to` takes two forms, and the object form is the one that
160
+ // survives a real navbar:
161
+ //
162
+ // ["work", "about"] by POSITION — link i goes to i
163
+ // { Work: "work", About: "about" } by LABEL — link text decides
164
+ //
165
+ // Position looks tidier and is wrong for anything but a toy nav. A
166
+ // responsive bar renders a DIFFERENT set of links per breakpoint
167
+ // (About/Services/Contact wide, About/About/Contact collapsed), so
168
+ // index i is not the same destination at two window sizes. And a
169
+ // nav that repeats a label sends two links to one arbitrary place.
170
+ // Labels are what the user is actually reading and clicking.
171
+ const byLabel = e.to && !Array.isArray(e.to) && typeof e.to === "object"
172
+ ? e.to : null;
173
+ const toIds = byLabel
174
+ ? [...new Set(Object.values(e.to))]
175
+ : (Array.isArray(e.to) ? e.to : [e.to]);
176
+
177
+ const labelToId = new Map();
178
+ if (byLabel) {
179
+ for (const [label, id] of Object.entries(e.to)) {
180
+ labelToId.set(norm(label), id);
181
+ }
182
+ }
183
+
184
+ return {
185
+ from: e.from, byLabel, toIds, labelToId,
186
+ effect: opt("effect"),
187
+ duration: opt("duration", 900),
188
+ // Written as `!== false` rather than a truthy test: the
189
+ // documented default is on, and only an explicit false is a
190
+ // request to leave the destination without a way back.
191
+ back: opt("back", true) !== false,
192
+ live: opt("live") === true,
193
+ fade: opt("fade", "morph"),
194
+ };
195
+ });
196
+ };
197
+
124
198
  /**
125
199
  * @param {Element} mount the container Des rendered into
126
200
  * @param {object[]} elements the E array, in order
@@ -148,39 +222,43 @@ function applyMorphNodes(mount, elements, nodes) {
148
222
  }
149
223
 
150
224
  function setUpMorph(mount, m, resolve) {
151
- const fromEl = resolve(m.from);
152
-
153
- // `to` takes two forms, and the object form is the one that survives
154
- // a real navbar:
225
+ // ── the controller's model ───────────────────────────────────────
155
226
  //
156
- // ["work", "about"] by POSITION link i goes to i
157
- // { Work: "work", About: "about" } by LABELlink text decides
227
+ // One controller owns one stage and EVERY state in the graph. The
228
+ // alternative a controller per edgecannot work: edge two's
229
+ // source is edge one's destination, which the first controller has
230
+ // already detached as a template, so two controllers would fight over
231
+ // the same element and stack two block-level stages.
158
232
  //
159
- // Position looks tidier and is wrong for anything but a toy nav. A
160
- // responsive bar renders a DIFFERENT set of links per breakpoint
161
- // (About/Services/Contact wide, About/About/Contact collapsed), so
162
- // index i is not the same destination at two window sizes. And a nav
163
- // that repeats a label sends two links to one arbitrary place. Labels
164
- // are what the user is actually reading and clicking.
165
- const byLabel = m.to && !Array.isArray(m.to) && typeof m.to === "object"
166
- ? m.to : null;
167
- const toIds = byLabel
168
- ? [...new Set(Object.values(m.to))]
169
- : (Array.isArray(m.to) ? m.to : [m.to]);
170
- const toEls = toIds.map(resolve);
171
- if (!fromEl || !toEls.some(Boolean)) {
172
- console.warn("[nodality] morph: could not resolve", m.from, "->", toIds);
173
- return null;
174
- }
175
- const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
176
- const labelToIndex = new Map();
177
- if (byLabel) {
178
- for (const [label, id] of Object.entries(m.to)) {
179
- const i = toIds.indexOf(id);
180
- if (i >= 0) labelToIndex.set(norm(label), i);
233
+ // States are keyed by id rather than held in a positional array,
234
+ // because an edge names its destinations by id and a chain's second
235
+ // edge starts from a state the first edge introduced. Position means
236
+ // nothing once there is more than one source.
237
+ const edges = normalizeEdges(m);
238
+ const rootEdge = edges[0];
239
+ const rootId = rootEdge && rootEdge.from;
240
+ const rootEl = rootId != null ? resolve(rootId) : null;
241
+
242
+ const states = new Map();
243
+ if (rootEl) states.set(rootId, rootEl);
244
+ for (const e of edges) {
245
+ const src = resolve(e.from);
246
+ if (src && !states.has(e.from)) states.set(e.from, src);
247
+ for (const id of e.toIds) {
248
+ const el = resolve(id);
249
+ if (el && !states.has(id)) states.set(id, el);
181
250
  }
182
251
  }
183
252
 
253
+ if (!rootEl || !rootEdge.toIds.some((id) => states.has(id))) {
254
+ console.warn("[nodality] morph: could not resolve",
255
+ rootId, "->", rootEdge ? rootEdge.toIds : []);
256
+ return null;
257
+ }
258
+
259
+ /** The edge leaving a state, or null where the graph ends. */
260
+ const edgeFrom = (id) => edges.find((e) => e.from === id) || null;
261
+
184
262
  // A positioned stage, standing where the source stood. The source and
185
263
  // the destinations are siblings inside it rather than in page flow,
186
264
  // because a morph interpolates a BOX and both ends need a common
@@ -188,7 +266,7 @@ function setUpMorph(mount, m, resolve) {
188
266
  const stage = document.createElement("div");
189
267
  stage.className = "nod-morph";
190
268
  stage.style.cssText = "position:relative;";
191
- mount.insertBefore(stage, fromEl);
269
+ mount.insertBefore(stage, rootEl);
192
270
 
193
271
  const rhost = document.createElement("div");
194
272
  rhost.className = "nod-morph-host";
@@ -202,55 +280,82 @@ function setUpMorph(mount, m, resolve) {
202
280
  stage.appendChild(rhost);
203
281
  stage.appendChild(live);
204
282
 
205
- live.appendChild(fromEl);
206
- fromEl.style.position = "absolute";
207
- fromEl.style.left = fromEl.style.top = "0";
208
-
209
- // Destinations start OFF the page. Without this every subview renders
210
- // stacked under the nav, which is the whole "why is everything
211
- // visible at once" problem.
212
- const templates = toEls.map((el) => {
213
- if (!el) return null;
214
- el.remove();
215
- return el;
216
- });
283
+ live.appendChild(rootEl);
284
+ rootEl.style.position = "absolute";
285
+ rootEl.style.left = rootEl.style.top = "0";
286
+
287
+ // Every state except the root starts OFF the page. Without this every
288
+ // subview renders stacked under the nav, which is the whole "why is
289
+ // everything visible at once" problem.
290
+ for (const [id, el] of states) {
291
+ if (id !== rootId) el.remove();
292
+ }
293
+
294
+ // Where the graph is now. `current` moves only when a transition
295
+ // COMPLETES — a morph that is interrupted half way has not arrived
296
+ // anywhere, and treating it as though it had is what makes interrupt
297
+ // handling incoherent. The history stack is the record of states
298
+ // actually landed on, which is what `back` unwinds.
299
+ let current = rootId;
300
+ const history = [];
301
+
302
+ const currentEl = () => states.get(current) || rootEl;
217
303
 
218
304
  const sizeStage = (h) => {
219
305
  stage.style.minHeight =
220
- Math.max(fromEl.getBoundingClientRect().height, h || 0) + 24 + "px";
306
+ Math.max(currentEl().getBoundingClientRect().height, h || 0) + 24 + "px";
221
307
  };
222
308
  sizeStage(0);
223
309
 
224
310
  let pipe = null;
225
311
  let tl = null;
226
312
  let active = -1;
313
+ // The two elements a running transition is between. The frame loop
314
+ // needs them by identity rather than by position in the host: from
315
+ // the second hop onward the outgoing state is ALSO a child of the
316
+ // host, so "the first child" no longer means "the destination".
317
+ let incoming = null;
318
+ let outgoing = null;
319
+ // The pair the CURRENT pipeline was built from, as state ids. A
320
+ // reversal may reuse that pipeline only when it is already exactly
321
+ // the hop being unwound — keying the decision on the history top
322
+ // instead let a stale pipeline satisfy it, resolve at zero
323
+ // immediately, and move the graph without moving the screen.
324
+ let lastFrom = null;
325
+ // Set while the root is being measured for a reversal INTO it. The
326
+ // root lives in the live layer, which is display:none whenever the
327
+ // graph is deeper in — and an element inside display:none has no box
328
+ // at all, so capturing it without this yields a 0x0 still and a
329
+ // transition that reverses into nothing.
330
+ let measuring = false;
331
+ let pipeOld = null; // state id on the pipeline's old side
332
+ let pipeNew = null; // state id on its new side
333
+
334
+ // A chain replaces one view with another in the same place; a single
335
+ // hop opens its destination BELOW the source and has done since the
336
+ // first morph shipped. gesos.cz renders with that placement today, so
337
+ // it stays exactly as it was, and only a genuine chain changes it —
338
+ // marching each successive state further down the page would also
339
+ // break hop two's arithmetic, which assumes a source at the top.
340
+ const inPlace = edges.length > 1;
227
341
 
228
- const goTo = async (i) => {
229
- const dest = templates[i];
230
- if (!dest) return;
231
- active = i;
232
-
233
- for (const p of activeRasterPipelines()) {
234
- if (stage.contains(p.canvas)) p.destroy();
235
- }
236
- rhost.innerHTML = "";
237
- rhost.appendChild(dest);
238
- // Hidden from the moment it enters the document. Between insertion
239
- // and applyRasterPipeline there are two awaited captures, and a
240
- // destination that is merely in the DOM paints for those frames —
241
- // which is the destination flashing before the morph runs.
242
- // visibility (not display) so it still lays out and can be measured.
243
- dest.style.visibility = "hidden";
244
-
342
+ /**
343
+ * Capture two states and stand a transition pipeline up between them.
344
+ *
345
+ * Shared by both directions. A reversal is not a forward transition
346
+ * to an earlier state: it is THIS, with the state being returned to
347
+ * passed as the old side, so the effect runs backwards rather than
348
+ * playing forwards into the past.
349
+ */
350
+ const buildTransition = async (oldEl, newEl, edge) => {
245
351
  // WAIT FOR IMAGES before measuring anything.
246
352
  //
247
353
  // An <img> has no intrinsic box until it decodes — even from a
248
354
  // data: URL, which is async too. Measure before that and the
249
- // destination's rect is its pre-image layout, so the shader draws
250
- // the capture into a box that does not match where the real card
251
- // ends up. The handover then pops between two different sizes,
252
- // which is the flash at the end of the transition.
253
- const imgs = [...dest.querySelectorAll("img")];
355
+ // rect is the pre-image layout, so the shader draws the capture
356
+ // into a box that does not match where the real card ends up.
357
+ // The handover then pops between two different sizes.
358
+ const imgs = [...newEl.querySelectorAll("img")];
254
359
  if (imgs.length) {
255
360
  await Promise.all(imgs.map((im) => {
256
361
  if (im.complete && im.naturalWidth) return null;
@@ -260,52 +365,268 @@ function setUpMorph(mount, m, resolve) {
260
365
  im.addEventListener("error", res, { once: true });
261
366
  });
262
367
  }));
263
- // One more frame so layout reflects the decoded intrinsic size.
264
368
  await new Promise((r) => requestAnimationFrame(r));
265
369
  }
266
370
 
267
- const fromRect = rectIn(rhost, fromEl);
268
- Object.assign(dest.style, { position: "absolute", left: "0",
269
- top: Math.round(fromRect.h + 20) + "px", boxSizing: "border-box" });
270
- const toRect = rectIn(rhost, dest);
271
- sizeStage(toRect.y + toRect.h);
371
+ const oldRect = rectIn(rhost, oldEl);
372
+ const newRect = rectIn(rhost, newEl);
373
+ sizeStage(Math.max(oldRect.y + oldRect.h, newRect.y + newRect.h));
272
374
 
273
375
  const [oldImage, newImage] = await Promise.all([
274
- capture(fromEl, fromRect.w, fromRect.h),
275
- capture(dest, toRect.w, toRect.h),
376
+ capture(oldEl, oldRect.w, oldRect.h),
377
+ capture(newEl, newRect.w, newRect.h),
276
378
  ]);
277
379
 
278
- pipe = applyRasterPipeline(rhost, chainFor(m.effect), {
380
+ pipe = applyRasterPipeline(rhost, chainFor(edge.effect), {
279
381
  transition: {
280
382
  oldImage, newImage,
281
- oldRect: fromRect, oldTo: toRect,
282
- newRect: toRect, newFrom: fromRect,
383
+ oldRect, oldTo: newRect,
384
+ newRect, newFrom: oldRect,
283
385
  // The morph owns the screen strictly BETWEEN its ends: the
284
386
  // real source presents at t=0, the real destination at t=1.
285
387
  standDownAtStart: true,
286
388
  // Opt back into the HTML-in-Canvas backend. Off by default
287
389
  // for a morph: it moves the content INTO the canvas, where
288
390
  // it becomes fallback content and stops being hit-testable.
289
- live: m.live === true,
391
+ live: edge.live === true,
290
392
  // Per-pixel choice rather than a uniform mix. A crossfade
291
393
  // shows 50% of each at the midpoint, which reads as two
292
394
  // states at once rather than one becoming the other.
293
- fade: m.fade || "morph",
395
+ fade: edge.fade || "morph",
294
396
  },
295
397
  });
296
398
 
297
399
  if (tl) tl.destroy();
298
- tl = progressTimeline(pipe, { duration: m.duration != null ? m.duration : 900 });
400
+ tl = progressTimeline(pipe, {
401
+ duration: edge.duration != null ? edge.duration : 900,
402
+ });
403
+ };
404
+
405
+ /**
406
+ * Transition from wherever the graph is now to `destId`.
407
+ *
408
+ * Keyed by state id rather than by index: an index is only meaningful
409
+ * relative to one edge's destination list, and the controller has to
410
+ * answer "go here" from whichever state happens to be current.
411
+ */
412
+ const goToState = async (destId) => {
413
+ const edge = edgeFrom(current);
414
+ if (!edge || !edge.toIds.includes(destId)) return;
415
+ const dest = states.get(destId);
416
+ if (!dest) return;
417
+ const srcEl = currentEl();
418
+ active = edge.toIds.indexOf(destId);
419
+ lastFrom = current;
420
+
421
+ for (const p of activeRasterPipelines()) {
422
+ if (stage.contains(p.canvas)) p.destroy();
423
+ }
424
+ // Clear everything EXCEPT the state being transitioned from.
425
+ // Emptying the host outright is what the single hop did, and it
426
+ // was safe only because the source lived in the live layer. From
427
+ // the second hop onward the source is a landed state sitting in
428
+ // this host, and emptying would delete the very element about to
429
+ // be captured. For a single hop the source is still in the live
430
+ // layer, so this removes exactly what it always did.
431
+ for (const child of [...rhost.children]) {
432
+ if (child !== srcEl) child.remove();
433
+ }
434
+ rhost.appendChild(dest);
435
+ outgoing = srcEl;
436
+ incoming = dest;
437
+ // Hidden from the moment it enters the document. Between insertion
438
+ // and applyRasterPipeline there are two awaited captures, and a
439
+ // destination that is merely in the DOM paints for those frames —
440
+ // which is the destination flashing before the morph runs.
441
+ // visibility (not display) so it still lays out and can be measured.
442
+ dest.style.visibility = "hidden";
443
+
444
+ // Place the destination before anything is measured: in a chain
445
+ // it takes the source's own position, so views replace each other
446
+ // in one region rather than marching down the page.
447
+ const fromRect = rectIn(rhost, srcEl);
448
+ Object.assign(dest.style, { position: "absolute", left: "0",
449
+ top: Math.round(inPlace ? fromRect.y : fromRect.h + 20) + "px",
450
+ boxSizing: "border-box" });
451
+
452
+ await buildTransition(srcEl, dest, edge);
453
+ if (!tl) return;
454
+ pipeOld = lastFrom; pipeNew = destId;
299
455
  tl.set(0);
300
456
 
301
- if (m.back !== false) {
302
- const btn = dest.querySelector("button");
303
- if (btn && !btn.dataset.nodMorphBack) {
304
- btn.dataset.nodMorphBack = "1";
305
- btn.addEventListener("click", () => { if (tl) tl.to(0); });
457
+ // Wire the destination BEFORE the timeline runs, not on arrival.
458
+ // Progress reaches 1 a frame before `to(1)`'s promise resolves, so
459
+ // wiring at landing leaves a window in which the destination is
460
+ // fully presented and its controls are dead — which a caller
461
+ // polling progress hits every time.
462
+ //
463
+ // Order still matters: forward triggers first, then back from
464
+ // whatever is left, so the roles cannot swap.
465
+ wireState(destId);
466
+ if (edge.back) claimBack(dest);
467
+
468
+ // `to()` resolves with the progress actually reached, and resolves
469
+ // rather than hanging when something interrupts it. So the
470
+ // resolved value IS the answer to "did we arrive?" — anything
471
+ // short of 1 means another transition took over, and a morph that
472
+ // did not arrive must not move the graph.
473
+ const reached = await tl.to(1);
474
+ if (reached === 1 && incoming === dest) land(destId, srcEl, edge);
475
+ };
476
+
477
+ /**
478
+ * Unwind one step of the path actually taken.
479
+ *
480
+ * `back` is history, not an edge. The graph may offer a route from
481
+ * here to somewhere else entirely — `aurora` has a forward edge to
482
+ * `contact` — but going back means returning the way you came, which
483
+ * is what every user already expects from a browser. So the stack of
484
+ * landed states decides the destination, not the edge list.
485
+ */
486
+ let backing = false;
487
+ let queuedBacks = 0;
488
+
489
+ /** Unwind exactly one step of the path taken. */
490
+ const stepBack = async () => {
491
+ if (!history.length) return;
492
+ const targetId = history[history.length - 1];
493
+ const targetEl = states.get(targetId);
494
+ if (!targetEl) return;
495
+ const fromEl = currentEl();
496
+
497
+ // The live pipeline is already the right pair when the last thing
498
+ // that ran was the hop being unwound. Reusing it is what makes an
499
+ // immediate back feel instant, and it is also what lets a back
500
+ // INTERRUPT a still-running forward — the timeline simply
501
+ // reverses from wherever it is.
502
+ if (pipe && tl && pipeOld === targetId && pipeNew === current) {
503
+ const reached = await tl.to(0);
504
+ if (reached === 0) landBack(targetId);
505
+ return;
506
+ }
507
+ await reverseRebuild(targetId, targetEl, fromEl);
508
+ };
509
+
510
+ const goBack = async () => {
511
+ // Presses arriving mid-reversal are QUEUED, not dropped and not
512
+ // run concurrently. Dropping them makes a user hammering back sit
513
+ // one level down and look stuck; running them at once re-targets
514
+ // the hop that just finished, because `current` updates a tick
515
+ // after progress reaches its endpoint. Queuing gives the only
516
+ // behaviour that matches the control: one press, one level.
517
+ if (backing) { queuedBacks++; return; }
518
+ backing = true;
519
+ try {
520
+ await stepBack();
521
+ while (queuedBacks > 0 && history.length) {
522
+ queuedBacks--;
523
+ await stepBack();
306
524
  }
525
+ } finally { backing = false; queuedBacks = 0; }
526
+ };
527
+
528
+ /** The reversal that cannot reuse the live pipeline: build it. */
529
+ const reverseRebuild = async (targetId, targetEl, fromEl) => {
530
+
531
+ // The old pipeline is kept ALIVE across the measuring phase and
532
+ // retired only once its replacement exists. It is what covers the
533
+ // stage while the state being returned to is revealed underneath
534
+ // it, and that ordering is what lets the reveal use `display`
535
+ // alone — no `visibility: hidden` anywhere.
536
+ //
537
+ // That matters beyond flicker. HTML-in-Canvas uploads an element
538
+ // through its PAINT RECORD, and a hidden element has none:
539
+ // `texElementImage2D` then throws "No cached paint record for
540
+ // element" and the pipeline falls back to snapshot mid-flight.
541
+ // Measuring something while hiding it is not an option on the
542
+ // live backend — it has to be painted, and merely covered.
543
+ //
544
+ // Retiring it too early is the other failure: destroying it
545
+ // before the replacement exists leaves a gap with nothing
546
+ // presenting, and never retiring it leaves its canvas stacked in
547
+ // the host showing a stale frame over the real transition.
548
+ const stale = [...activeRasterPipelines()].filter(
549
+ (p) => stage.contains(p.canvas));
550
+
551
+ const edge = edgeFrom(targetId) || {};
552
+ if (targetEl === rootEl) {
553
+ // Revealed, not shown: it needs a box to be measured and a
554
+ // paint record to be uploaded. The stale canvas above covers
555
+ // it until the transition takes over.
556
+ measuring = true;
557
+ live.style.display = "";
558
+ } else if (targetEl.parentNode !== rhost) {
559
+ // A later hop swept it out of the host; it has to be back in
560
+ // the document to be measured and captured.
561
+ targetEl.style.visibility = "hidden";
562
+ rhost.appendChild(targetEl);
563
+ const cur = rectIn(rhost, fromEl);
564
+ Object.assign(targetEl.style, {
565
+ position: "absolute", left: "0",
566
+ top: Math.round(inPlace ? cur.y : 0) + "px",
567
+ boxSizing: "border-box",
568
+ });
307
569
  }
308
- tl.to(1);
570
+
571
+ await buildTransition(targetEl, fromEl, edge);
572
+ for (const p of stale) p.destroy();
573
+ measuring = false;
574
+ if (!tl) return;
575
+ pipeOld = targetId; pipeNew = current;
576
+ outgoing = targetEl;
577
+ incoming = fromEl;
578
+ tl.set(1);
579
+ const reached = await tl.to(0);
580
+ if (reached === 0) landBack(targetId);
581
+ };
582
+
583
+ /** Arriving back: pop the step being unwound and present it. */
584
+ const landBack = (targetId) => {
585
+ if (history[history.length - 1] === targetId) history.pop();
586
+ current = targetId;
587
+ lastFrom = history.length ? history[history.length - 1] : null;
588
+ wireState(targetId);
589
+ };
590
+
591
+ /**
592
+ * A transition completed: the destination is now where the graph is,
593
+ * and therefore now a source in its own right.
594
+ */
595
+ const land = (id, previousEl, viaEdge) => {
596
+ // The step just completed becomes the top of the stack — this is
597
+ // what `back` unwinds, and why back returns the way you came
598
+ // rather than following some edge that happens to point earlier.
599
+ if (lastFrom != null && lastFrom !== id) history.push(lastFrom);
600
+ current = id;
601
+ // The state left behind STAYS in the host, hidden by the frame
602
+ // loop. Removing it here was wrong in both directions: going back
603
+ // has to present it again, and the shader's t=0 frame is a
604
+ // picture of it, so the real element must be there to take over.
605
+ // The next forward transition sweeps it out, because the
606
+ // selective clear keeps only the state it departs from.
607
+ outgoing = previousEl && previousEl !== rootEl ? previousEl : null;
608
+ // Idempotent re-wire. The destination was wired before the
609
+ // timeline ran; this covers anything its own subtree changed
610
+ // during the transition.
611
+ wireState(id);
612
+ if (viaEdge && viaEdge.back) claimBack(states.get(id));
613
+ };
614
+
615
+ /** The back control is a button the forward wiring did not claim. */
616
+ const claimBack = (el) => {
617
+ if (!el) return;
618
+ const btn = [...el.querySelectorAll("button")].find(
619
+ (b) => !b.dataset.nodMorphWired && !b.dataset.nodMorphBack);
620
+ if (!btn) return;
621
+ btn.dataset.nodMorphBack = "1";
622
+ btn.addEventListener("click", () => { goBack(); });
623
+ };
624
+
625
+ // Positional entry point, kept for the handle's published shape: the
626
+ // index is resolved against the CURRENT state's edge.
627
+ const goTo = (i) => {
628
+ const edge = edgeFrom(current);
629
+ return edge ? goToState(edge.toIds[i]) : undefined;
309
630
  };
310
631
 
311
632
  // Link i in the source goes to destination i. By POSITION, not label:
@@ -321,45 +642,75 @@ function setUpMorph(mount, m, resolve) {
321
642
  * case a plain call-to-action button is. If the source is itself the
322
643
  * control (a lone button), the source is the trigger.
323
644
  */
324
- const triggersOf = () => {
325
- const links = [...fromEl.querySelectorAll("a")];
645
+ const triggersOf = (el) => {
646
+ const links = [...el.querySelectorAll("a")];
326
647
  if (links.length) return links;
327
- const btns = [...fromEl.querySelectorAll("button, [role=button]")];
648
+ const btns = [...el.querySelectorAll("button, [role=button]")];
328
649
  if (btns.length) return btns;
329
- return [fromEl];
650
+ return [el];
330
651
  };
331
652
 
332
- const wire = (a) => {
653
+ /**
654
+ * Wire one trigger against the edge leaving its state.
655
+ *
656
+ * `byName` says the edge's labels actually match something in this
657
+ * state, and it decides which rule applies. The single-destination
658
+ * shortcut — any trigger goes to the only destination — is right for
659
+ * a lone call-to-action button whose text matches no declared label,
660
+ * and wrong for a card that has both a forward control and a back
661
+ * control: there, "any trigger" sweeps up the back button too. When
662
+ * the labels match, the labels rule.
663
+ */
664
+ const wire = (a, edge, triggers, byName) => {
333
665
  if (a.dataset.nodMorphWired) return;
334
- const triggers = triggersOf();
335
- // One destination means there is nothing to disambiguate: any
336
- // trigger goes there, whatever it is labelled. Only a fan-out
337
- // needs a label map or an index.
338
- const idx = toIds.length === 1 ? 0
339
- : byLabel ? labelToIndex.get(norm(a.textContent))
340
- : triggers.indexOf(a);
341
- if (idx == null || idx < 0 || !templates[idx]) return;
666
+ // The back control is claimed first and is never also a forward
667
+ // trigger. The two wirings guard on independent flags, so a state
668
+ // whose only controls are buttons `triggersOf` falls through to
669
+ // buttons — and whose edge has a single destination any trigger
670
+ // goes there would otherwise fire BOTH on one click.
671
+ if (a.dataset.nodMorphBack) return;
672
+ const destId = byName ? edge.labelToId.get(norm(a.textContent))
673
+ // One destination and no matching label: nothing to
674
+ // disambiguate, so any trigger goes there.
675
+ : edge.toIds.length === 1 ? edge.toIds[0]
676
+ : edge.byLabel ? edge.labelToId.get(norm(a.textContent))
677
+ : edge.toIds[triggers.indexOf(a)];
678
+ if (!destId || !states.has(destId)) return;
342
679
  a.dataset.nodMorphWired = "1";
343
- a.dataset.nodMorphTo = toIds[idx];
680
+ a.dataset.nodMorphTo = destId;
344
681
  a.addEventListener("click", (e) => {
345
682
  // Nothing resolved -> the link keeps its own behaviour, which
346
683
  // on an ordinary page is real navigation.
347
- if (!templates[idx]) return;
684
+ if (!states.has(destId)) return;
348
685
  e.preventDefault();
349
- goTo(idx);
686
+ goToState(destId);
350
687
  });
351
688
  };
352
- const wireAll = () => triggersOf().forEach(wire);
353
- wireAll();
354
-
355
- // A responsive nav REPLACES its links when it crosses a breakpoint —
356
- // the Switcher swaps the whole view — so anything wired once is gone
357
- // after a resize. Re-wire whatever appears; `nodMorphWired` keeps it
358
- // idempotent.
359
- if (typeof MutationObserver !== "undefined") {
360
- new MutationObserver(wireAll).observe(fromEl,
361
- { childList: true, subtree: true });
362
- }
689
+
690
+ /** Wire every trigger of a state, if that state has an outgoing edge. */
691
+ const observed = new Set();
692
+ const wireState = (id) => {
693
+ const edge = edgeFrom(id);
694
+ const el = states.get(id);
695
+ if (!edge || !el) return;
696
+ const triggers = triggersOf(el);
697
+ const byName = !!edge.byLabel &&
698
+ triggers.some((t) => edge.labelToId.has(norm(t.textContent)));
699
+ triggers.forEach((t) => wire(t, edge, triggers, byName));
700
+
701
+ // A responsive nav REPLACES its links when it crosses a
702
+ // breakpoint — the Switcher swaps the whole view — so anything
703
+ // wired once is gone after a resize. Re-wire whatever appears;
704
+ // `nodMorphWired` keeps it idempotent. Every state that can be a
705
+ // source gets this, not just the root, because a chain can be
706
+ // resized while three levels deep.
707
+ if (typeof MutationObserver !== "undefined" && !observed.has(id)) {
708
+ observed.add(id);
709
+ new MutationObserver(() => wireState(id))
710
+ .observe(el, { childList: true, subtree: true });
711
+ }
712
+ };
713
+ wireState(rootId);
363
714
 
364
715
  // Only the source is on screen until something is chosen.
365
716
  const frame = () => {
@@ -403,22 +754,42 @@ function setUpMorph(mount, m, resolve) {
403
754
  // canvas alone showed BOTH ends at t=1, because at that point the
404
755
  // canvas is down and nothing distinguished the two.
405
756
  const handover = !pipe || !canvasUp;
406
- const wantLive = handover && t <= 0 ? "" : "none";
757
+ // The live layer holds the ROOT state only. Landed states live in
758
+ // the host, so from the second hop onward "t is at zero" does not
759
+ // mean the root should be showing — `current` decides that.
760
+ const wantLive = measuring ? ""
761
+ : handover && t <= 0 && (current === rootId || outgoing === rootEl)
762
+ ? "" : "none";
407
763
  if (live.style.display !== wantLive) live.style.display = wantLive;
408
- const dest = rhost.firstElementChild;
764
+ // By identity, not by position: during a chain hop the outgoing
765
+ // state is also a child of this host, so `firstElementChild`
766
+ // would sometimes name the wrong one of the two.
767
+ const dest = incoming || rhost.firstElementChild;
409
768
  if (dest && dest.style) {
410
769
  const want = pipe && handover && t >= 1 ? "" : "hidden";
411
770
  if (dest.style.visibility !== want) dest.style.visibility = want;
412
771
  }
772
+ // The state being left behind is the other end of the same rule:
773
+ // it presents at t=0, and only while the canvas has stood down.
774
+ // The root is exempt because the live layer's display already
775
+ // governs it, and driving both would fight.
776
+ if (outgoing && outgoing !== rootEl && outgoing.style) {
777
+ const want = handover && t <= 0 ? "" : "hidden";
778
+ if (outgoing.style.visibility !== want) outgoing.style.visibility = want;
779
+ }
413
780
  requestAnimationFrame(frame);
414
781
  };
415
782
  requestAnimationFrame(frame);
416
783
 
417
784
  return {
418
- stage, from: fromEl, destinations: templates,
785
+ stage, from: rootEl,
786
+ get destinations() { return rootEdge.toIds.map((id) => states.get(id) || null); },
787
+ get states() { return states; },
788
+ get current() { return current; },
789
+ get history() { return [...history]; },
419
790
  get progress() { return pipe ? pipe.progress : 0; },
420
791
  get active() { return active; },
421
- goTo,
792
+ goTo, goToState,
422
793
  back() { if (tl) tl.to(0); },
423
794
  destroy() {
424
795
  if (tl) tl.destroy();