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
package/lib/raster-ops.js CHANGED
@@ -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
  */
@@ -23,11 +23,21 @@
23
23
  // so fidelity is high. Static: recaptured on resize (or via
24
24
  // handle.refresh()). External images / webfonts do not load inside
25
25
  // the SVG image context — system fonts and data: URIs only.
26
- // live (opt-in: any raster node carries `live: true`) — the emerging
27
- // HTML-in-Canvas API (WICG, Chrome origin trial): the mount subtree
28
- // moves inside the effect <canvas layoutsubtree> and is uploaded per
29
- // frame with gl.texElementImage2D(), staying interactive and live.
30
- // Falls back to snapshot when the API is absent.
26
+ // live (the DEFAULT where it applies; opt OUT with `live: false` on any
27
+ // node) — the emerging HTML-in-Canvas API (WICG, Chrome origin trial):
28
+ // the mount subtree moves inside the effect <canvas layoutsubtree> and
29
+ // is uploaded per frame with gl.texElementImage2D(), staying
30
+ // interactive and live. Falls back to snapshot when the API is absent,
31
+ // and again if no paint event arrives within 1500ms.
32
+ //
33
+ // "Where it applies" = any chain that is not PURE overlay. A
34
+ // pure-overlay chain (blobs alone) restructures nothing, so there is
35
+ // no live subtree to sample and it stays on snapshot. See `wantLive`
36
+ // in applyRasterPipeline, which is the authority:
37
+ // !pureOverlay && !nodes.some(n => n.live === false)
38
+ // (This said "opt-in: any raster node carries `live: true`" until
39
+ // 2026-08-12. It had been the opposite of the code for some time —
40
+ // `live: true` is not read anywhere.)
31
41
  //
32
42
  // Safety: this module touches nothing at import time, so it is inert
33
43
  // under jsdom prerender (nodality/ssg). applyRasterPipeline() returns
@@ -44,8 +54,30 @@
44
54
  // Ops execute in nodes-array order within their stage. Uniforms are
45
55
  // namespaced per node index so the same op can appear twice.
46
56
 
57
+ // Same "did you mean" matching the element mapper and morph use, so a
58
+ // mistyped stage reads like a mistyped element type. Pure module, no DOM
59
+ // — importing it keeps this file inert at import (phase P1).
60
+ import { didYouMean } from "./suggest.js";
61
+
47
62
  const MAX_BLOBS = 12;
48
63
 
64
+ // The unit vocabulary doc.params draws on. Not enforcement — nothing
65
+ // converts by unit — but it is what lets the inspector label a control
66
+ // and pick a sane step, and a closed list means "pixels" vs "px" vs "PX"
67
+ // cannot drift across 15 ops.
68
+ const RASTER_UNITS = [
69
+ "px", // a length in CSS pixels; the op scales it by dpr itself
70
+ "ratio", // 0..1, or a multiplier — never scaled by dpr
71
+ "deg", // degrees, converted to radians at upload
72
+ "count", // an integer quantity of things
73
+ "color", // "#rrggbb"
74
+ "name", // an identifier: a field name, a driver, a blend mode
75
+ "point", // [x, y], fractions of the element box
76
+ "range", // [lo, hi], a remap
77
+ "bool", // present/absent toggle
78
+ "seconds", // a duration or a rate per second
79
+ ];
80
+
49
81
  // ── Drivers ──────────────────────────────────────────────────────────
50
82
  // What steers a reactive op. Previously "react to the pointer" was
51
83
  // hardcoded into offset and duotone; a driver makes the input a value in
@@ -78,6 +110,24 @@ const DRIVER_NAMES = Object.keys(DRIVERS);
78
110
 
79
111
  const REGISTRY = {
80
112
  hexalize: {
113
+ doc: {
114
+ summary: "Hexagonal cell grid. Snaps sampling to a hex lattice and "
115
+ + "draws cell borders; with `lift`, cells near the driver swell "
116
+ + "toward the viewer and their content magnifies to match.",
117
+ params: {
118
+ size: { default: 24, unit: "px", summary: "lattice pitch — the width of one cell" },
119
+ lift: {
120
+ default: 0, unit: "ratio", structural: true,
121
+ summary: "how much bigger a cell gets at the focus: 0.3 renders the "
122
+ + "nearest hexagons at 1.3x. 0 compiles the cheap path entirely away, "
123
+ + "so crossing zero rebuilds.",
124
+ },
125
+ radius: { default: 200, unit: "px", summary: "falloff distance around the driver focus" },
126
+ },
127
+ },
128
+ // `lift` at 0 compiles the cheap path; non-zero compiles the
129
+ // seven-neighbour probe. Only crossing zero needs a new shader.
130
+ structuralOnToggle: ["lift"],
81
131
  stage: "cell",
82
132
  decl: (p) => `
83
133
  uniform float ${p}size;
@@ -181,9 +231,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
181
231
  lift: ["1f", node.lift != null ? node.lift : 0],
182
232
  radius: ["1f", (node.radius || 200) * dpr],
183
233
  }),
234
+ // Without `lift` the cheap path never writes `warped` — it only
235
+ // sets center/edge — so the coordinate is unchanged. WITH lift it
236
+ // probes seven neighbours to find which grown hexagon covers the
237
+ // fragment, and mirroring that on the CPU would be a second
238
+ // implementation of the subtlest code here. Returns null instead,
239
+ // which routes the query to the GPU readback.
240
+ map: (pt, node) => (node.lift ? null : pt),
184
241
  },
185
242
 
186
243
  offset: {
244
+ doc: {
245
+ summary: "Pushes the coordinate space away from the driver focus. Warp "
246
+ + "stage, so cell grids and borders move WITH the content instead of "
247
+ + "staying a fixed screen-space lattice.",
248
+ params: {
249
+ strength: { default: 20, unit: "px", summary: "how far the space is pushed at full amount" },
250
+ radius: { default: 260, unit: "px", summary: "falloff distance around the focus" },
251
+ },
252
+ },
187
253
  // Warp-stage: displaces the coordinate space before the cell
188
254
  // grid is computed, so tiles — content AND borders — move
189
255
  // together away from the pointer. (The old displace-stage
@@ -206,9 +272,136 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
206
272
  strength: ["1f", (node.strength || 20) * dpr],
207
273
  radius: ["1f", (node.radius || 260) * dpr],
208
274
  }),
275
+ // Phase I2. The CPU twin of code() above, so a hit-test costs no
276
+ // GPU sync. Checked against the readback rather than trusted —
277
+ // see the property test in raster-probe.spec.js.
278
+ map: (pt, node, ctx) => {
279
+ const strength = (node.strength || 20) * ctx.dpr;
280
+ const radius = (node.radius || 260) * ctx.dpr;
281
+ const vx = pt[0] - ctx.dpos[0], vy = pt[1] - ctx.dpos[1];
282
+ const d = Math.hypot(vx, vy);
283
+ const fall = 1 - smoothstep(0, radius, d);
284
+ if (!(d > 0.5)) return pt;
285
+ const k = (strength * fall * ctx.damt) / d;
286
+ return [pt[0] - vx * k, pt[1] - vy * k];
287
+ },
288
+ },
289
+
290
+ // Curl-noise flow: the coordinate space drifts along a divergence-free
291
+ // vector field, so content slides as if carried by a current. Warp
292
+ // stage, which buys two things for free — it is maskable (STAGE_VARS
293
+ // snapshots `warped`) and it composes with every cell and colour op,
294
+ // because those run downstream of it.
295
+ //
296
+ // { op: "flow", strength: 18, scale: 120 } // whole element
297
+ // { op: "flow", by: "mouse", radius: 320 } // a current at the pointer
298
+ // { op: "flow", by: "time", speed: 0.4 } // hands-free drift
299
+ //
300
+ // Divergence-free BY CONSTRUCTION: the field is the curl of a scalar
301
+ // potential ψ, and curl of a gradient-free scalar has zero divergence.
302
+ // That is what makes it read as a fluid without a pressure solve —
303
+ // `stir` pays for a full Stable-Fluids projection to get a field that
304
+ // also responds to input; this one is free and static-by-seed.
305
+ //
306
+ // Without `by:` the current fills the element. With a driver, strength
307
+ // falls off around the focus (the `duotone`/`halftone` convention), so
308
+ // the effect resolves away from the pointer instead of being global.
309
+ flow: {
310
+ doc: {
311
+ summary: "Curl-noise current. The coordinate space drifts along a "
312
+ + "divergence-free field, so content slides as if carried by water. "
313
+ + "Divergence-free means the flow never piles up or tears.",
314
+ params: {
315
+ strength: { default: 18, unit: "px", summary: "how far the current carries the space" },
316
+ scale: { default: 120, unit: "px", summary: "size of one eddy — small is turbulent, large is a slow drift" },
317
+ speed: { default: 0.25, unit: "seconds", summary: "how fast the field evolves; 0 freezes it" },
318
+ seed: { default: 0, unit: "count", summary: "picks a different field of the same character" },
319
+ radius: {
320
+ default: 320, unit: "px",
321
+ summary: "falloff around the focus. Only used when `by` names a driver; "
322
+ + "without one the current covers the whole element.",
323
+ },
324
+ },
325
+ },
326
+ structural: ["by"],
327
+ stage: "warp",
328
+ decl: (p) => `
329
+ uniform float ${p}scale;
330
+ uniform float ${p}strength;
331
+ uniform float ${p}speed;
332
+ uniform float ${p}seed;
333
+ uniform float ${p}radius;
334
+ uniform vec2 ${p}dpos;
335
+ uniform float ${p}damt;
336
+ // Polynomial bit-mixing hash, deliberately NOT the usual
337
+ // fract(sin(dot(...))). Trig-based hashes disagree across GPU
338
+ // vendors — sin's precision at large arguments is not
339
+ // specified — so the same seed would render a different field
340
+ // on different hardware. This one is multiply/fract only, so
341
+ // the field is at least stable per-device. (Determinism rule,
342
+ // MORPH-IMPL-SPEC §2.9.5a.)
343
+ float ${p}h(vec2 v) {
344
+ vec3 q = fract(vec3(v.xyx) * 0.1031 + ${p}seed);
345
+ q += dot(q, q.yzx + 33.33);
346
+ return fract((q.x + q.y) * q.z);
347
+ }
348
+ float ${p}n(vec2 v) {
349
+ vec2 i = floor(v), f = v - i;
350
+ f = f * f * (3.0 - 2.0 * f);
351
+ return mix(mix(${p}h(i), ${p}h(i + vec2(1.0, 0.0)), f.x),
352
+ mix(${p}h(i + vec2(0.0, 1.0)), ${p}h(i + vec2(1.0, 1.0)), f.x), f.y);
353
+ }
354
+ // The potential. Two octaves: enough structure to read as a
355
+ // current without the cost of a full fBm.
356
+ float ${p}psi(vec2 v) {
357
+ return ${p}n(v) * 0.65 + ${p}n(v * 2.3 + 11.0) * 0.35;
358
+ }`,
359
+ code: (p, node) => `
360
+ {
361
+ float sc = max(${p}scale, 1.0);
362
+ vec2 drift = vec2(0.0, u_time * ${p}speed);${node.by ? `
363
+ float fall = 1.0 - smoothstep(0.0, ${p}radius, length(warped - ${p}dpos));
364
+ float amt = ${p}strength * fall * ${p}damt;` : `
365
+ float amt = ${p}strength;`}
366
+ // Three fixed Euler steps along curl(psi). Fixed count, not
367
+ // adaptive: the step budget is a compile-time property so the
368
+ // shader cost cannot vary with the content.
369
+ for (int i = 0; i < 3; i++) {
370
+ vec2 q = warped / sc + drift;
371
+ float e = 0.35;
372
+ float dy = ${p}psi(q + vec2(0.0, e)) - ${p}psi(q - vec2(0.0, e));
373
+ float dx = ${p}psi(q + vec2(e, 0.0)) - ${p}psi(q - vec2(e, 0.0));
374
+ // curl of a 2D scalar potential = (dpsi/dy, -dpsi/dx)
375
+ warped += vec2(dy, -dx) / (2.0 * e) * amt * 0.3333;
376
+ }
377
+ }`,
378
+ uniforms: (node, dpr) => ({
379
+ // Feature size of the current, in px. Larger = broader, slower
380
+ // -turning eddies.
381
+ scale: ["1f", (node.scale != null ? node.scale : 120) * dpr],
382
+ // Displacement in px. Scaled by dpr: it is a length.
383
+ strength: ["1f", (node.strength != null ? node.strength : 18) * dpr],
384
+ speed: ["1f", node.speed != null ? node.speed : 0.25],
385
+ // NOT dpr-scaled — it selects a field, it is not a length.
386
+ seed: ["1f", node.seed != null ? node.seed : 0],
387
+ radius: ["1f", (node.radius != null ? node.radius : 320) * dpr],
388
+ }),
209
389
  },
210
390
 
211
391
  duotone: {
392
+ doc: {
393
+ summary: "Maps luminance onto a two-colour ramp. With `by`, the mapping "
394
+ + "is confined to a spot around the driver focus and the rest of the "
395
+ + "element keeps its own colour.",
396
+ params: {
397
+ colors: {
398
+ default: ["#104B87", "#E8FF00"], unit: "color",
399
+ summary: "[shadow, highlight] — the two ends of the ramp",
400
+ },
401
+ radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
402
+ },
403
+ },
404
+ structural: ["by"],
212
405
  stage: "color",
213
406
  decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;
214
407
  uniform vec2 ${p}dpos; uniform float ${p}damt;`,
@@ -239,6 +432,15 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
239
432
  // e.g. { op: "edges", color: "#FFFFFF" } gives white borders under
240
433
  // the text. When present it also replaces the default seam darkening.
241
434
  edges: {
435
+ doc: {
436
+ summary: "Draws the cell borders a cell-stage op produced. On its own it "
437
+ + "shows nothing — it colours `edge`, which only hexalize (or another "
438
+ + "cell op) writes.",
439
+ params: {
440
+ color: { default: "#FFFFFF", unit: "color", summary: "border colour" },
441
+ strength: { default: 1.0, unit: "ratio", summary: "border opacity" },
442
+ },
443
+ },
242
444
  stage: "color",
243
445
  decl: (p) => `uniform vec3 ${p}color; uniform float ${p}strength;`,
244
446
  code: (p) => `
@@ -285,9 +487,24 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
285
487
  // With a driver the screen coarsens toward the focus, like holding a
286
488
  // loupe over the sheet.
287
489
  halftone: {
490
+ doc: {
491
+ summary: "Print halftone: the image is redrawn as a grid of ink dots on "
492
+ + "paper, dot size following local luminance.",
493
+ params: {
494
+ size: { default: 6, unit: "px", summary: "dot pitch" },
495
+ angle: { default: 15, unit: "deg", summary: "screen angle of the dot grid" },
496
+ ink: { default: "#0B1B2B", unit: "color", summary: "dot colour" },
497
+ paper: { default: "#FFFFFF", unit: "color", summary: "background colour" },
498
+ softness: { default: 0.08, unit: "ratio", summary: "dot edge softness — 0 is a hard stencil" },
499
+ amount: { default: 1, unit: "ratio", summary: "blend back toward the un-screened image; 0 is identity. Keyframe it to 0 at both ends of a transition so the shader hands over to the real element without a pop." },
500
+ radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
501
+ },
502
+ },
503
+ structural: ["by"],
288
504
  stage: "color",
289
505
  decl: (p) => `
290
506
  uniform float ${p}size;
507
+ uniform float ${p}amount;
291
508
  uniform float ${p}angle;
292
509
  uniform vec3 ${p}ink;
293
510
  uniform vec3 ${p}paper;
@@ -311,10 +528,11 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
311
528
  // sqrt because ink coverage goes as area, not radius.
312
529
  float rr = sqrt(clamp(1.0 - lum, 0.0, 1.0)) * 0.55;
313
530
  float dm = smoothstep(rr, rr - ${p}soft, length(cell));
314
- col = mix(${p}paper, ${p}ink, dm);
531
+ col = mix(col, mix(${p}paper, ${p}ink, dm), clamp(${p}amount, 0.0, 1.0));
315
532
  }`,
316
533
  uniforms: (node, dpr) => ({
317
534
  size: ["1f", (node.size || 6) * dpr],
535
+ amount: ["1f", node.amount != null ? node.amount : 1],
318
536
  angle: ["1f", ((node.angle != null ? node.angle : 15) * Math.PI) / 180],
319
537
  ink: ["3fv", hexToRgb(node.ink || "#0B1B2B")],
320
538
  paper: ["3fv", hexToRgb(node.paper || "#FFFFFF")],
@@ -341,6 +559,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
341
559
  //
342
560
  // `by` coarsens the pattern toward the driver focus, as halftone does.
343
561
  dither: {
562
+ doc: {
563
+ summary: "Ordered (Bayer) dithering to a small palette — the look of an "
564
+ + "8-bit display, and the one op that gets sharper as the element gets "
565
+ + "smaller.",
566
+ params: {
567
+ size: { default: 1, unit: "px", summary: "size of one dither cell; 1 is per-pixel" },
568
+ levels: { default: 6, unit: "count", summary: "quantisation steps per channel" },
569
+ amount: { default: 1, unit: "ratio", summary: "blend back toward the undithered image" },
570
+ mono: {
571
+ default: false, unit: "bool", structural: true,
572
+ summary: "quantise luminance to ink/paper instead of per-channel colour. "
573
+ + "A different shader, so toggling rebuilds.",
574
+ },
575
+ ink: { default: "#0B1B2B", unit: "color", summary: "dark end, mono only" },
576
+ paper: { default: "#FFFFFF", unit: "color", summary: "light end, mono only" },
577
+ radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
578
+ },
579
+ },
580
+ structural: ["by", "mono"],
344
581
  stage: "color",
345
582
  decl: (p) => `
346
583
  uniform float ${p}levels;
@@ -400,6 +637,20 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
400
637
  // { op: "aberration", amount: 6 } // radial, lens-like
401
638
  // { op: "aberration", amount: 14, by: "mouse" } // focused at the pointer
402
639
  aberration: {
640
+ // Displace stage, but it only spreads the per-channel taps
641
+ // (`chroma`) — the primary sample position is untouched. Declared
642
+ // so a hit-test can skip it rather than give up on the CPU path;
643
+ // a unit test checks this against the op's own source.
644
+ movesCoords: false,
645
+ doc: {
646
+ summary: "Chromatic aberration: the three colour channels are sampled "
647
+ + "at slightly different offsets, like a cheap lens.",
648
+ params: {
649
+ amount: { default: 6, unit: "px", summary: "channel separation" },
650
+ radius: { default: 240, unit: "px", summary: "falloff around the focus when `by` names a driver" },
651
+ },
652
+ },
653
+ structural: ["by"],
403
654
  stage: "displace",
404
655
  chroma: true,
405
656
  decl: (p) => `
@@ -439,6 +690,30 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
439
690
 
440
691
  // Geometric / content masks.
441
692
  mask: {
693
+ doc: {
694
+ summary: "Writes a scalar FIELD for later ops to read — draws nothing "
695
+ + "itself. Give a downstream op `masked: \"name\"` and it applies only "
696
+ + "where this field is high. This is how any op becomes local without "
697
+ + "knowing anything about masking.",
698
+ params: {
699
+ as: {
700
+ default: "mask", unit: "name", structural: true,
701
+ summary: "name of the field written. Consumers name it in `masked:`.",
702
+ },
703
+ from: {
704
+ default: "radial", unit: "name", structural: true,
705
+ summary: "field shape — compiled in, so changing it rebuilds",
706
+ },
707
+ at: {
708
+ default: [0.5, 0.5], unit: "point", structural: true,
709
+ summary: "centre as a fraction of the element box; survives resizes",
710
+ },
711
+ radius: { default: 260, unit: "px", summary: "field radius" },
712
+ invert: { default: false, unit: "bool", summary: "swap inside and outside" },
713
+ remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
714
+ },
715
+ },
716
+ structural: ["as", "from", "at"],
442
717
  stage: "field",
443
718
  producesField: true,
444
719
  defaultDriver: "static",
@@ -483,6 +758,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
483
758
  // { op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
484
759
  // { op: "offset", masked: "turbulence", strength: 30 },
485
760
  noise: {
761
+ doc: {
762
+ summary: "Writes an animated noise FIELD. Same contract as `mask` — it "
763
+ + "draws nothing; downstream ops read it via `masked:`. Use it to make "
764
+ + "any other op flicker, breathe or crawl.",
765
+ params: {
766
+ as: {
767
+ default: "mask", unit: "name", structural: true,
768
+ summary: "name of the field written",
769
+ },
770
+ scale: { default: 3, unit: "ratio", summary: "noise frequency across the element" },
771
+ speed: { default: 0.3, unit: "seconds", summary: "how fast it evolves; 0 freezes it" },
772
+ amount: { default: 1, unit: "ratio", summary: "blend between flat 1.0 and full noise" },
773
+ remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
774
+ },
775
+ },
776
+ structural: ["as"],
486
777
  stage: "field",
487
778
  producesField: true,
488
779
  decl: (p) => `
@@ -535,6 +826,35 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
535
826
  // earlier colour ops, so a copy of a halftoned element is a copy of
536
827
  // the unhalftoned source with the screen applied over it.
537
828
  copy: {
829
+ // Phase I4. Draws N stamps of the ORIGINAL texture over the
830
+ // result, so one screen point genuinely shows several sources and
831
+ // `sourceAt` can only report the primary sample. There is no
832
+ // correct single answer here, only a documented one.
833
+ multiSample: true,
834
+ doc: {
835
+ summary: "Stamps extra copies of the element over itself. Copies sample "
836
+ + "the ORIGINAL texture, not the result of earlier colour ops, so a copy "
837
+ + "of a halftoned element is a copy of the unhalftoned source.",
838
+ params: {
839
+ count: {
840
+ default: 5, unit: "count", structural: true,
841
+ summary: "stamps arranged in a ring around the focus. The loop is "
842
+ + "unrolled at compile time, so this rebuilds — not something to animate.",
843
+ },
844
+ points: {
845
+ default: null, unit: "point", structural: true,
846
+ summary: "explicit [[x,y], ...] in fractions of the element box, instead "
847
+ + "of a ring. Survives resizes. Overrides `count`.",
848
+ },
849
+ radius: { default: 110, unit: "px", summary: "ring radius when using `count`" },
850
+ scale: { default: 0.5, unit: "ratio", summary: "size of each copy" },
851
+ rotate: { default: 0, unit: "deg", summary: "rotation applied per copy" },
852
+ fade: { default: 1, unit: "ratio", summary: "opacity falloff across the set" },
853
+ amount: { default: 1, unit: "ratio", summary: "overall blend of the copies over the original" },
854
+ },
855
+ },
856
+ // `count`/`points` unroll the stamp loop at compile time.
857
+ structural: ["count", "points"],
538
858
  stage: "color",
539
859
  defaultDriver: "static",
540
860
  decl: (p) => `
@@ -605,6 +925,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
605
925
  // no extra framebuffer and no second capture of the DOM. It cannot,
606
926
  // for the same reason, give the branches different source content.
607
927
  merge: {
928
+ doc: {
929
+ summary: "Runs two sub-chains over the same source and blends the "
930
+ + "results. The only op that takes other ops as arguments — everything "
931
+ + "else composes by sequence, this composes in parallel.",
932
+ params: {
933
+ a: { default: null, summary: "first branch: an array of nodes", structural: true },
934
+ b: { default: null, summary: "second branch: an array of nodes", structural: true },
935
+ mode: {
936
+ default: "over", unit: "name", structural: true,
937
+ summary: "how the branches combine: over, add, screen, multiply, "
938
+ + "difference, lighten. Compiled in, so it rebuilds.",
939
+ },
940
+ mix: { default: 1, unit: "ratio", summary: "how far to blend b into a" },
941
+ },
942
+ },
943
+ structural: ["mode", "a", "b"],
608
944
  // Never routed through the normal stage path — emitMerge()
609
945
  // handles it — but it still needs decl/uniforms so its `mix`
610
946
  // gets declared and uploaded like any other op's.
@@ -632,6 +968,17 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
632
968
  // skeleton composites underneath the content, so the ghosts sit
633
969
  // behind the live text rather than veiling it.
634
970
  echo: {
971
+ doc: {
972
+ summary: "Temporal accumulation — a fading trail of what CHANGED. Typing "
973
+ + "smears, a ticking counter leaves a comet. The one op that does nothing "
974
+ + "on the snapshot backend, because a frozen image has no motion to record.",
975
+ params: {
976
+ strength: { default: 0.85, unit: "ratio", summary: "opacity of the trail" },
977
+ decay: { default: 0.92, unit: "ratio", summary: "per-second persistence — higher is a longer tail" },
978
+ tint: { default: "#7FD4FF", unit: "color", summary: "colour pushed into the trail" },
979
+ tintAmount: { default: 0.5, unit: "ratio", summary: "how far the trail is tinted" },
980
+ },
981
+ },
635
982
  stage: "color",
636
983
  decl: (p) => `
637
984
  uniform sampler2D ${p}hist;
@@ -747,6 +1094,29 @@ void main() {
747
1094
  // Needs WebGL2 with float render targets; without them the op
748
1095
  // disables itself and the rest of the chain still renders.
749
1096
  stir: {
1097
+ doc: {
1098
+ summary: "Stirred liquid: a real fluid simulation the content is dragged "
1099
+ + "through. STATEFUL — it owns a ping-pong pair of render targets that "
1100
+ + "evolve frame to frame, so it is the most expensive op here.",
1101
+ params: {
1102
+ strength: { default: 26, unit: "px", summary: "how far the fluid carries the content" },
1103
+ force: { default: 1.0, unit: "ratio", summary: "how hard the pointer pushes the fluid" },
1104
+ radius: { default: 0.1, unit: "ratio", summary: "splat size, as a fraction of the sim grid" },
1105
+ curl: { default: 2.2, unit: "ratio", summary: "vorticity — how much the fluid curls into eddies" },
1106
+ decay: { default: 0.985, unit: "ratio", summary: "per-second velocity persistence" },
1107
+ pressure: { default: 0.8, unit: "ratio", summary: "pressure retained between frames" },
1108
+ pressureIterations: { default: 6, unit: "count", summary: "solver iterations; higher is stiffer and slower" },
1109
+ intensity: { default: 1.6, unit: "ratio", summary: "brightness of the dye layer" },
1110
+ sheen: { default: 0.15, unit: "ratio", summary: "specular highlight on the surface" },
1111
+ tint: { default: "#7FD4FF", unit: "color", summary: "dye colour, unless `rainbow`" },
1112
+ rainbow: { default: false, unit: "bool", summary: "cycle dye hue instead of using `tint`" },
1113
+ dye: { default: 0.85, unit: "ratio", summary: "how much dye colour reaches the final image" },
1114
+ dyeAmount: { default: 0.6, unit: "ratio", summary: "how much dye each splat injects" },
1115
+ dyeFade: { default: 0.97, unit: "ratio", summary: "per-second dye persistence" },
1116
+ resolution: { default: 128, unit: "count", summary: "velocity grid size, clamped 32..512" },
1117
+ dyeResolution: { default: 512, unit: "count", summary: "dye grid size, clamped 64..1024" },
1118
+ },
1119
+ },
750
1120
  stage: ["warp", "color"],
751
1121
  decl: (p) => `
752
1122
  uniform sampler2D ${p}vel;
@@ -1079,6 +1449,32 @@ void main() {
1079
1449
  // gradient. init()/tick() run the particle sim on the CPU and feed
1080
1450
  // the positions in as a per-frame uniform array.
1081
1451
  blobs: {
1452
+ doc: {
1453
+ summary: "Liquid-glass blobs that follow the pointer: a chain of "
1454
+ + "metaballs refracting the content behind them, stretching apart on a "
1455
+ + "fast flick and relaxing back to a single circle at rest.",
1456
+ params: {
1457
+ count: {
1458
+ default: 1, unit: "count", structural: true,
1459
+ summary: "independent drifting blobs, on top of the trail chain",
1460
+ },
1461
+ trail: {
1462
+ default: 7, unit: "count", structural: true,
1463
+ summary: "blobs in the follow-the-leader chain. Unrolled at compile "
1464
+ + "time, so it rebuilds.",
1465
+ },
1466
+ radius: { default: 46, unit: "px", summary: "blob radius at rest" },
1467
+ color: { default: "#EAF4EE", unit: "color", summary: "glass tint" },
1468
+ refract: { default: 70, unit: "px", summary: "how far the glass bends what is behind it" },
1469
+ rim: { default: 0.6, unit: "ratio", summary: "brightness of the rim light" },
1470
+ alpha: { default: 1.0, unit: "ratio", summary: "overall opacity" },
1471
+ iridescence: { default: 0.8, unit: "ratio", summary: "colour shift across the rim" },
1472
+ dispersion: { default: 1.0, unit: "ratio", summary: "per-channel refraction spread" },
1473
+ frost: { default: 0.25, unit: "ratio", summary: "blur of what shows through" },
1474
+ },
1475
+ },
1476
+ // Read by init(), which allocates the particle chain.
1477
+ structural: ["count", "trail"],
1082
1478
  stage: "color",
1083
1479
  overlay: true,
1084
1480
  mips: true,
@@ -1266,15 +1662,18 @@ void main() {
1266
1662
  chain[0].y = state.head.y;
1267
1663
  const follow = 1 - Math.exp(-dt * 16);
1268
1664
  for (let i = 1; i < chain.length; i++) {
1269
- const lead = chain[i - 1], node = chain[i];
1270
- let dx = lead.x - node.x, dy = lead.y - node.y;
1665
+ // `b` is a blob in the chain, NOT the raster node — it was
1666
+ // called `node` and shadowed the op's own parameter, which
1667
+ // reads as this op having x/y params it does not have.
1668
+ const lead = chain[i - 1], b = chain[i];
1669
+ let dx = lead.x - b.x, dy = lead.y - b.y;
1271
1670
  const d = Math.hypot(dx, dy) || 1e-4;
1272
1671
  // Cap the lag so a fast flick can't tear the tube apart,
1273
1672
  // then ease the remainder — gives an elastic pull.
1274
1673
  const maxLag = state.seg;
1275
- if (d > maxLag) { node.x += dx * (1 - maxLag / d); node.y += dy * (1 - maxLag / d); }
1276
- node.x += (lead.x - node.x) * follow;
1277
- node.y += (lead.y - node.y) * follow;
1674
+ if (d > maxLag) { b.x += dx * (1 - maxLag / d); b.y += dy * (1 - maxLag / d); }
1675
+ b.x += (lead.x - b.x) * follow;
1676
+ b.y += (lead.y - b.y) * follow;
1278
1677
  }
1279
1678
 
1280
1679
  // Stretch = how far the tail trails the head, normalised by the
@@ -1320,6 +1719,12 @@ void main() {
1320
1719
  },
1321
1720
  };
1322
1721
 
1722
+ // GLSL smoothstep, for the CPU coordinate twins (phase I2).
1723
+ function smoothstep(e0, e1, x) {
1724
+ const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0 || 1e-6)));
1725
+ return t * t * (3 - 2 * t);
1726
+ }
1727
+
1323
1728
  function hexToRgb(c) {
1324
1729
  return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255);
1325
1730
  }
@@ -1333,11 +1738,258 @@ function hexToRgb(c) {
1333
1738
  // node carries `target:`; the sub-chains inherit whatever it matched.
1334
1739
  const RASTER_OP_NAMES = Object.keys(REGISTRY).concat("switch");
1335
1740
 
1741
+ /**
1742
+ * How an op behaves under hit-testing (phase I4).
1743
+ *
1744
+ * DERIVED from what the op already declares rather than hand-listed, so
1745
+ * the taxonomy cannot drift from the contract the way a maintained table
1746
+ * would. The classes, and what each means for `pipeline.sourceAt`:
1747
+ *
1748
+ * "neutral" does not move the sampling coordinate, so the browser's
1749
+ * own hit-testing was already right (halftone, duotone,
1750
+ * and aberration, which spreads only the per-channel taps)
1751
+ * "analytic" declares a CPU twin that always answers, so a hit-test
1752
+ * costs no GPU sync (offset, and the pixelate example)
1753
+ * "conditional" declares a twin that answers only for some parameters —
1754
+ * hexalize is identity without `lift` and declines with it
1755
+ * "readback" moves coordinates with no twin, so every query is an
1756
+ * exact GPU round trip (flow, stir)
1757
+ * "many-to-one" one screen point maps to several sources; sourceAt
1758
+ * reports the primary sample (copy)
1759
+ *
1760
+ * @param {string} op
1761
+ * @returns {"neutral"|"analytic"|"conditional"|"readback"|"many-to-one"|"unknown"}
1762
+ */
1763
+ function interactionClass(op) {
1764
+ const def = REGISTRY[op];
1765
+ if (!def) return "unknown";
1766
+ if (def.multiSample) return "many-to-one";
1767
+ const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
1768
+ const movesStage = stages.some((s) => s === "warp" || s === "cell" || s === "displace");
1769
+ if (!movesStage || def.movesCoords === false) return "neutral";
1770
+ if (typeof def.map !== "function") return "readback";
1771
+ // A twin that can decline for some parameter values is not the same
1772
+ // promise as one that always answers, and the difference is exactly
1773
+ // what a caller budgeting for GPU syncs needs to know.
1774
+ return /return\s+null|\?\s*null|null\s*:/.test(String(def.map)) ? "conditional" : "analytic";
1775
+ }
1776
+
1777
+ // ── Phase T1: keyframed params ───────────────────────────────────────
1778
+ //
1779
+ // A param may be given as an array of numbers, sampled over transition
1780
+ // progress `t`:
1781
+ //
1782
+ // { op: "flow", strength: [0, 40, 0] } // in, peak, out
1783
+ //
1784
+ // The problem this has to solve: plenty of params are LEGITIMATELY
1785
+ // arrays. `mask.at` is [x, y], `remap` is [lo, hi], `duotone.colors` is
1786
+ // a pair, `copy.points` is a list, `merge.a` is a sub-chain. Guessing
1787
+ // from the value alone would break all of them.
1788
+ //
1789
+ // H4's `doc.params` already carries the answer: a param whose declared
1790
+ // unit is a SCALAR quantity, given as two or more numbers, is keyframes.
1791
+ // Anything else is data. So this is another property bought by the
1792
+ // declaration an op already makes, and an op with no doc never
1793
+ // keyframes — which is the safe direction to be wrong in.
1794
+ const KEYFRAMABLE_UNITS = ["px", "ratio", "deg", "count", "seconds"];
1795
+
1796
+ function isKeyframed(op, key, value) {
1797
+ if (!Array.isArray(value) || value.length < 2) return false;
1798
+ for (const v of value) if (typeof v !== "number" || !Number.isFinite(v)) return false;
1799
+ const def = REGISTRY[op];
1800
+ const meta = def && def.doc && def.doc.params && def.doc.params[key];
1801
+ return !!meta && KEYFRAMABLE_UNITS.includes(meta.unit);
1802
+ }
1803
+
1804
+ /** Piecewise-linear sample of a keyframe array at t in [0, 1]. */
1805
+ function sampleKeyframes(kf, t) {
1806
+ const n = kf.length - 1;
1807
+ const x = Math.min(1, Math.max(0, t)) * n;
1808
+ const i = Math.min(n - 1, Math.floor(x));
1809
+ return kf[i] + (kf[i + 1] - kf[i]) * (x - i);
1810
+ }
1811
+
1812
+ // ── Phase T3: choreography ───────────────────────────────────────────
1813
+ //
1814
+ // Two node-level controls, both acting on the LOCAL progress an op's
1815
+ // keyframes are sampled against. Neither is a new authoring language —
1816
+ // they reshape t, and everything downstream is unchanged.
1817
+ //
1818
+ // window: [0.2, 0.8] this op's keyframes span that slice of t, so
1819
+ // several ops in one chain can stagger
1820
+ // ease: "in-out" easing applied to the local progress
1821
+ //
1822
+ // Easing lives here rather than in the timeline because a chain wants
1823
+ // ops on different curves; the timeline that drives `t` stays linear and
1824
+ // therefore still scrubbable and reversible (P-1).
1825
+ const EASINGS = {
1826
+ linear: (x) => x,
1827
+ in: (x) => x * x * x,
1828
+ out: (x) => 1 - Math.pow(1 - x, 3),
1829
+ "in-out": (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
1830
+ // Overshoots past 1 and settles — the classic UI "pop". Keyframes are
1831
+ // sampled with clamping, so an overshoot reads as a hold at the end
1832
+ // unless the author gives it somewhere to go.
1833
+ back: (x) => 1 + 2.70158 * Math.pow(x - 1, 3) + 1.70158 * Math.pow(x - 1, 2),
1834
+ };
1835
+ const EASING_NAMES = Object.keys(EASINGS);
1836
+
1837
+ /** An op's own progress: its window, then its easing. */
1838
+ function localProgress(node, t) {
1839
+ let x = Math.min(1, Math.max(0, t));
1840
+ const w = node.window;
1841
+ if (Array.isArray(w) && w.length === 2 && w[1] !== w[0]) {
1842
+ x = Math.min(1, Math.max(0, (x - w[0]) / (w[1] - w[0])));
1843
+ }
1844
+ const ease = node.ease && EASINGS[node.ease];
1845
+ return ease ? ease(x) : x;
1846
+ }
1847
+
1848
+ /**
1849
+ * The node as the op should see it at progress `t` — keyframe arrays
1850
+ * collapsed to scalars, everything else untouched.
1851
+ *
1852
+ * Returns the ORIGINAL object when nothing is keyframed, so the common
1853
+ * case allocates nothing per frame.
1854
+ */
1855
+ function resolveNode(node, t) {
1856
+ let out = null;
1857
+ const local = (node.window || node.ease) ? localProgress(node, t) : t;
1858
+ for (const key in node) {
1859
+ if (!isKeyframed(node.op, key, node[key])) continue;
1860
+ out = out || Object.assign({}, node);
1861
+ out[key] = sampleKeyframes(node[key], local);
1862
+ }
1863
+ return out || node;
1864
+ }
1865
+
1866
+ /** Does this node carry any keyframed param? */
1867
+ function hasKeyframes(node) {
1868
+ for (const key in node) if (isKeyframed(node.op, key, node[key])) return true;
1869
+ return false;
1870
+ }
1871
+
1336
1872
  // Extension surface — mirrors the CSS-level operation registry:
1337
1873
  // registerRasterOp("pixelate", { stage, decl, code, uniforms }).
1874
+ //
1875
+ // This used to be two lines that assigned and returned. Every way of
1876
+ // getting it wrong therefore succeeded, and surfaced later as a shader
1877
+ // that failed to compile with no indication of which op wrote the bad
1878
+ // line — or, worse, as an op that compiled and did nothing, because a
1879
+ // misspelt `stage` matched no stage and its code was never emitted.
1880
+ // That is the silently-ignored-key class again, one level up: not a bad
1881
+ // option on a node, but a bad op in the registry.
1882
+ //
1883
+ // Registration is a one-time cost paid at startup, so it validates
1884
+ // eagerly and throws with the vocabulary listed. Everything here is
1885
+ // checkable without a GL context, which is why it runs at registration
1886
+ // rather than at first compile.
1887
+ function validateRasterOp(name, def) {
1888
+ const where = `registerRasterOp("${name}")`;
1889
+ if (typeof name !== "string" || !name.trim()) {
1890
+ throw new TypeError("[nodality] registerRasterOp needs a non-empty name");
1891
+ }
1892
+ if (!def || typeof def !== "object") {
1893
+ throw new TypeError(`[nodality] ${where}: definition must be an object`);
1894
+ }
1895
+
1896
+ // stage — the one field whose typo is invisible at runtime.
1897
+ const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
1898
+ if (def.stage == null || !stages.length) {
1899
+ throw new TypeError(`[nodality] ${where}: missing "stage". ` +
1900
+ `Valid stages: ${RASTER_STAGES.join(", ")}.`);
1901
+ }
1902
+ for (const s of stages) {
1903
+ if (typeof s !== "string" || !RASTER_STAGES.includes(s)) {
1904
+ throw new TypeError(`[nodality] ${where}: ` +
1905
+ didYouMean(s, RASTER_STAGES, "stage"));
1906
+ }
1907
+ }
1908
+
1909
+ // The two functions that produce GLSL, and the optional one that
1910
+ // feeds it. `uniforms` is optional because an op may be entirely
1911
+ // compile-time (see `copy`, which unrolls its loop in code()).
1912
+ for (const k of ["decl", "code"]) {
1913
+ if (typeof def[k] !== "function") {
1914
+ throw new TypeError(`[nodality] ${where}: "${k}" must be a function ` +
1915
+ `(got ${def[k] === undefined ? "nothing" : typeof def[k]}).`);
1916
+ }
1917
+ }
1918
+ if (def.uniforms != null && typeof def.uniforms !== "function") {
1919
+ throw new TypeError(`[nodality] ${where}: "uniforms" must be a function if present.`);
1920
+ }
1921
+ // Phase I2. `map` is the CPU twin of the op's coordinate arithmetic;
1922
+ // `movesCoords: false` says a coordinate-stage op leaves the sampling
1923
+ // position alone (aberration only spreads the per-channel taps). Both
1924
+ // let a hit-test skip the GPU readback, so a wrong shape here would
1925
+ // silently route every query the slow way.
1926
+ if (def.map != null && typeof def.map !== "function") {
1927
+ throw new TypeError(`[nodality] ${where}: "map" must be a function if present.`);
1928
+ }
1929
+ if (def.movesCoords != null && typeof def.movesCoords !== "boolean") {
1930
+ throw new TypeError(`[nodality] ${where}: "movesCoords" must be a boolean if present.`);
1931
+ }
1932
+
1933
+ // Rebuild hints. A name here that is not a real param is not fatal,
1934
+ // but the shape being wrong means isStructuralChange() would throw
1935
+ // mid-drag, so the array-of-strings part is enforced.
1936
+ for (const k of ["structural", "structuralOnToggle"]) {
1937
+ if (def[k] == null) continue;
1938
+ if (!Array.isArray(def[k]) || def[k].some((s) => typeof s !== "string")) {
1939
+ throw new TypeError(`[nodality] ${where}: "${k}" must be an array of param names.`);
1940
+ }
1941
+ }
1942
+
1943
+ if (def.defaultDriver != null && !DRIVER_NAMES.includes(def.defaultDriver)) {
1944
+ throw new TypeError(`[nodality] ${where}: ` +
1945
+ didYouMean(def.defaultDriver, DRIVER_NAMES, "driver"));
1946
+ }
1947
+
1948
+ // `doc` is optional — a third-party op stays registerable without it,
1949
+ // and the inspector falls back to introspection. But a doc that IS
1950
+ // supplied has to be the shape every reader assumes, or the panel
1951
+ // throws while rendering someone else's op.
1952
+ if (def.doc != null) {
1953
+ if (typeof def.doc !== "object") {
1954
+ throw new TypeError(`[nodality] ${where}: "doc" must be an object.`);
1955
+ }
1956
+ if (typeof def.doc.summary !== "string" || !def.doc.summary.trim()) {
1957
+ throw new TypeError(`[nodality] ${where}: "doc.summary" must be a non-empty string.`);
1958
+ }
1959
+ const params = def.doc.params;
1960
+ if (params != null) {
1961
+ if (typeof params !== "object" || Array.isArray(params)) {
1962
+ throw new TypeError(`[nodality] ${where}: "doc.params" must be an object ` +
1963
+ `keyed by param name.`);
1964
+ }
1965
+ for (const [k, v] of Object.entries(params)) {
1966
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1967
+ throw new TypeError(`[nodality] ${where}: doc.params.${k} must be an ` +
1968
+ `object like { default, unit }.`);
1969
+ }
1970
+ if (v.unit != null && !RASTER_UNITS.includes(v.unit)) {
1971
+ throw new TypeError(`[nodality] ${where}: doc.params.${k}: ` +
1972
+ didYouMean(v.unit, RASTER_UNITS, "unit"));
1973
+ }
1974
+ }
1975
+ }
1976
+ }
1977
+ return def;
1978
+ }
1979
+
1338
1980
  function registerRasterOp(name, def) {
1981
+ validateRasterOp(name, def);
1982
+ // Replacing a built-in is legitimate — overriding `halftone` with your
1983
+ // own is a reason this surface exists — but doing it by accident,
1984
+ // because two libraries picked the same word, is not. Warn rather than
1985
+ // throw: the caller may well mean it, and there is no way to tell.
1986
+ if (Object.prototype.hasOwnProperty.call(REGISTRY, name) &&
1987
+ typeof console !== "undefined" && console.warn) {
1988
+ console.warn(`[nodality] registerRasterOp("${name}") replaces an existing op.`);
1989
+ }
1339
1990
  REGISTRY[name] = def;
1340
1991
  if (!RASTER_OP_NAMES.includes(name)) RASTER_OP_NAMES.push(name);
1992
+ return def;
1341
1993
  }
1342
1994
 
1343
1995
  function isHTMLInCanvasAvailable() {
@@ -1359,6 +2011,71 @@ const STAGE_VARS = {
1359
2011
  color: ["col", "edgeCol", "edgeCov", "ovCol", "ovA"],
1360
2012
  };
1361
2013
 
2014
+ // What `stage:` may say. Derived from STAGE_VARS rather than written out
2015
+ // again, so adding a stage cannot leave the validator rejecting it.
2016
+ // `field` is the exception that has no stage vars of its own: a field op
2017
+ // writes a scalar for later ops to read, it does not modify the frame.
2018
+ //
2019
+ // Read by validateRasterOp, which is defined above this line but only
2020
+ // ever RUNS from registerRasterOp — i.e. after this module has finished
2021
+ // evaluating. Nothing registers an op during our own module eval.
2022
+ const RASTER_STAGES = ["field", ...Object.keys(STAGE_VARS)];
2023
+
2024
+ // Params the PIPELINE reads off every node, whatever the op. An op does
2025
+ // not declare these and must not document them — they would be fifteen
2026
+ // identical paragraphs that drift apart on the first edit. Defined once
2027
+ // here; the inspector merges them in, and API.md documents them once.
2028
+ const FRAMEWORK_DOC = {
2029
+ op: { default: null, unit: "name", summary: "which op this node is" },
2030
+ target: {
2031
+ default: null, unit: "name",
2032
+ summary: "element ids this node applies to, e.g. [\"#hero\"]",
2033
+ },
2034
+ side: {
2035
+ default: null, unit: "name", structural: true,
2036
+ summary: "\"old\" or \"new\" — scope this op to ONE half of a morph " +
2037
+ "instead of the blended result, so the outgoing and incoming " +
2038
+ "states can be art-directed differently. Colour-stage ops only: " +
2039
+ "both sides share one sampling coordinate, so a sided warp has " +
2040
+ "no meaning. Ignored outside a transition.",
2041
+ },
2042
+ by: {
2043
+ default: "static", unit: "name", structural: true,
2044
+ summary: `what steers the effect: ${DRIVER_NAMES.join(", ")}. ` +
2045
+ "Compiled in, so changing it rebuilds.",
2046
+ },
2047
+ masked: {
2048
+ default: false, unit: "name",
2049
+ summary: "read a field written upstream — true for the default " +
2050
+ "\"mask\", or a name. Works for ANY op; the op needs no knowledge of it.",
2051
+ },
2052
+ live: {
2053
+ default: true, unit: "bool",
2054
+ summary: "false forces the snapshot backend for this element " +
2055
+ "instead of HTML-in-Canvas capture",
2056
+ },
2057
+ // Phase T3. Both reshape the progress this node's keyframes are
2058
+ // sampled against; neither is read by any op.
2059
+ window: {
2060
+ default: null, unit: "range",
2061
+ summary: "[from, to] slice of transition progress this node's " +
2062
+ "keyframes span — how ops in one chain stagger",
2063
+ },
2064
+ ease: {
2065
+ default: "linear", unit: "name",
2066
+ summary: `easing for this node's local progress: ${EASING_NAMES.join(", ")}`,
2067
+ },
2068
+ interactive: {
2069
+ default: true, unit: "bool",
2070
+ summary: "false opts this pipeline out of pointer retargeting",
2071
+ },
2072
+ hoverAttr: {
2073
+ default: false, unit: "bool",
2074
+ summary: "mirror hover onto [data-nodality-hover] at the DRAWN " +
2075
+ "position; costs the zero-DOM-mutation property, so opt-in",
2076
+ },
2077
+ };
2078
+
1362
2079
  // A node opts into a field with `masked: true` (the default field) or
1363
2080
  // `masked: "name"`. Field producers declare `as:` to name what they
1364
2081
  // write. This is the whole of the node-to-node data flow: one op writes
@@ -1484,7 +2201,16 @@ function emitMerge(rec, buckets) {
1484
2201
  }
1485
2202
  }
1486
2203
 
1487
- function buildFragmentShader(recs, flat) {
2204
+ /**
2205
+ * @param {boolean} [probe] emit the coordinate-readback variant (phase I1)
2206
+ * instead of the visible one. Identical up to and including the
2207
+ * displace stage; then writes the packed source coordinate rather
2208
+ * than a colour.
2209
+ */
2210
+ function buildFragmentShader(recs, flat, probe, transition) {
2211
+ // "opaque" (default) keeps the union solid; "dissolve" is a true
2212
+ // cross-dissolve. See nodBlend.
2213
+ const fade = (transition && transition.fade) || "opaque";
1488
2214
  let decls = "";
1489
2215
 
1490
2216
  // Every field any node writes or reads, declared up front at 1.0 so
@@ -1504,8 +2230,71 @@ function buildFragmentShader(recs, flat) {
1504
2230
  if (def) decls += def.decl(`u${i}_`, node) + "\n";
1505
2231
  });
1506
2232
 
2233
+ // PER-SIDE effects. A node may carry `side: "old" | "new"`, which
2234
+ // scopes it to one half of a morph instead of the blended result.
2235
+ //
2236
+ // Everything else in a transition chain decorates the CROSSFADE — it
2237
+ // runs after old and new have already been mixed, so both ends get
2238
+ // the same treatment. That cannot express "the outgoing state burns
2239
+ // out while the incoming one develops", which is the thing a designer
2240
+ // actually reaches for. A sided op runs on its own side's colour
2241
+ // BEFORE nodBlend sees it.
2242
+ //
2243
+ // Colour stage only, and deliberately: warp/displace ops rewrite the
2244
+ // sampling coordinate, and there is one coordinate shared by both
2245
+ // sides — honouring `side` there would mean two independent
2246
+ // coordinate pipelines. A sided warp is rejected loudly rather than
2247
+ // silently ignored.
2248
+ const sideOf = (rec) => {
2249
+ const v = rec && rec.node && rec.node.side;
2250
+ if (v !== "old" && v !== "new") return null;
2251
+ const def = REGISTRY[rec.node.op];
2252
+ const stages = def ? (Array.isArray(def.stage) ? def.stage : [def.stage]) : [];
2253
+ if (!stages.every((st) => st === "color")) {
2254
+ console.warn(`[nodality] "${rec.node.op}" is a ${stages.join("+")} op, ` +
2255
+ `so side:"${v}" cannot apply to it — a sided op must be colour-only, ` +
2256
+ `because both sides share one sampling coordinate. Running it on ` +
2257
+ `the blended result instead.`);
2258
+ return null;
2259
+ }
2260
+ if (!transition) {
2261
+ console.warn(`[nodality] side:"${v}" has no meaning without a ` +
2262
+ `transition — there is only one image. Ignoring it.`);
2263
+ return null;
2264
+ }
2265
+ return v;
2266
+ };
2267
+ const mainRecs = recs.filter((r) => !sideOf(r));
2268
+ const oldRecs = recs.filter((r) => sideOf(r) === "old");
2269
+ const newRecs = recs.filter((r) => sideOf(r) === "new");
2270
+
1507
2271
  const buckets = emptyBuckets();
1508
- emitStages(recs, buckets);
2272
+ emitStages(mainRecs, buckets);
2273
+
2274
+ // One scope per side, with the colour stage variables declared local
2275
+ // so an op that touches edgeCol/ovA compiles here exactly as it does
2276
+ // in main(). `frag` is the sample position, which is what a sided op
2277
+ // means by "where am I".
2278
+ const sideBlock = (list, src) => {
2279
+ if (!list.length) return "";
2280
+ const b = emptyBuckets();
2281
+ emitStages(list, b);
2282
+ if (!b.color) return "";
2283
+ const decls = STAGE_VARS.color
2284
+ .filter((v) => v !== "col")
2285
+ .map((v) => ` ${glslType(v)} ${v} = ${glslType(v) === "vec3"
2286
+ ? "vec3(0.0)" : "0.0"};`).join("\n");
2287
+ return ` {
2288
+ vec2 frag = p;
2289
+ vec3 col = ${src}.rgb;
2290
+ ${decls}
2291
+ ${b.color}
2292
+ ${src}.rgb = col;
2293
+ }
2294
+ `;
2295
+ };
2296
+ const oldSideCode = sideBlock(oldRecs, "o");
2297
+ const newSideCode = sideBlock(newRecs, "n");
1509
2298
  const { field, warp, cell, displace, color } = buckets;
1510
2299
  const nodes = flat;
1511
2300
  // Compositing, in three layers (all straight-alpha):
@@ -1520,19 +2309,161 @@ function buildFragmentShader(recs, flat) {
1520
2309
  // Per-channel resampling costs two extra fetches, so it is only
1521
2310
  // emitted when a displace-stage op actually asks for dispersion.
1522
2311
  const hasChroma = nodes.some((n) => (REGISTRY[n.op] || {}).chroma);
1523
- const chromaFetch = hasChroma ? `
2312
+ // In transition mode the per-channel taps must see BOTH captures —
2313
+ // sampling u_tex alone would drop the old element out of the red and
2314
+ // blue channels for the whole morph.
2315
+ const chromaFetch = !hasChroma ? "" : (transition ? `
2316
+ vec2 cdev = vec2(chroma.x, chroma.y);
2317
+ col.r = nodSampleAt(sampleP + cdev).r;
2318
+ col.b = nodSampleAt(sampleP - cdev).b;` : `
1524
2319
  vec2 cpx = vec2(chroma.x, -chroma.y) / u_res;
1525
2320
  col.r = texture2D(u_tex, clamp(uv + cpx, 0.001, 0.999)).r;
1526
- col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;` : "";
2321
+ col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;`);
2322
+ // Phase I1. The PROBE variant is the same shader with a different last
2323
+ // line: instead of compositing a colour it writes the source
2324
+ // coordinate the fragment sampled from. Everything between `main()`
2325
+ // and the tail is byte-identical to the visible pass, which is the
2326
+ // whole point — a hit-test that recomputed the coordinate its own way
2327
+ // could disagree with what is actually drawn, and that disagreement
2328
+ // would be invisible.
2329
+ //
2330
+ // In probe mode the fragment coordinate is SUPPLIED rather than
2331
+ // derived: `u_probe` names the pixel being asked about, so a 1x1
2332
+ // framebuffer with a plain viewport can stand in for any pixel of the
2333
+ // full-size render. No full-resolution attachment is allocated, and
2334
+ // no negative-origin viewport is needed — an earlier attempt offset
2335
+ // the viewport instead and rasterised nothing, which readback
2336
+ // reported as a confident (0, 0).
2337
+ const probeDecl = probe ? "uniform vec2 u_probe;\n" : "";
2338
+ const fragCoord = probe ? "u_probe + vec2(0.5)" : "gl_FragCoord.xy";
2339
+ // Two bytes per axis: hi in one channel, lo in the next, so a 4000px
2340
+ // element resolves to about 0.06px. Decoded in sourceAt().
2341
+ const probeTail = `
2342
+ vec2 q = clamp(sampleP / u_res, 0.0, 1.0) * 255.0;
2343
+ vec2 hi = floor(q);
2344
+ vec2 lo = floor(fract(q) * 255.0);
2345
+ gl_FragColor = vec4(hi.x, lo.x, hi.y, lo.y) / 255.0;
2346
+ }`;
2347
+
1527
2348
  return `
1528
2349
  precision highp float;
1529
2350
  uniform sampler2D u_tex;
1530
2351
  uniform vec2 u_res;
1531
2352
  uniform vec2 u_mouse;
1532
2353
  uniform float u_time;
1533
- ${decls}
2354
+ uniform float u_t; // phase T1: transition progress, 0..1
2355
+
2356
+ // Op uniforms are declared HERE, above the transition helpers, because a
2357
+ // per-side op's code is emitted inside nodSampleAt. GLSL requires
2358
+ // declaration before use, and with the declarations further down every
2359
+ // sided op failed to compile with "'u0_amount' : undeclared identifier".
2360
+ ${probeDecl}${decls}
2361
+ ${transition ? `
2362
+ // ── phase T2: transition mode ──────────────────────────────────────
2363
+ // Two captures instead of one. u_tex is the NEW element, u_old the
2364
+ // frozen OLD one, and u_box is the content box they are both drawn
2365
+ // into — lerped from the old rect to the new rect by u_t. So geometry
2366
+ // (the box) and pixels (the effect chain) stay separate problems, which
2367
+ // is the split View Transitions also makes and the one that keeps this
2368
+ // maintainable.
2369
+ uniform sampler2D u_old;
2370
+ ${transition && transition.newImage ? "uniform sampler2D u_newimg;" : ""}
2371
+ // Phase T2, gap 2. TWO boxes, not one. With a single shared box the
2372
+ // only expressible motion is "both sides march together", so the classic
2373
+ // "old slides out while new slides in" was impossible. Each capture now
2374
+ // interpolates in its own rect, which keeps per-side motion in the
2375
+ // GEOMETRY tier (P-3) instead of needing per-side op scoping.
2376
+ uniform vec4 u_boxOld; // x, y, w, h — device px, top-down
2377
+ uniform vec4 u_boxNew;
2378
+
2379
+ // sRGB <-> linear. A naive crossfade in gamma space dips visibly in
2380
+ // brightness at mid-fade on photographic content; this is the classic
2381
+ // artifact and it is why the mix below is not just mix().
2382
+ vec3 nodToLinear(vec3 c) { return pow(c, vec3(2.2)); }
2383
+ vec3 nodToSRGB(vec3 c) { return pow(c, vec3(1.0 / 2.2)); }
2384
+
2385
+ // Premultiplied, so anti-aliased edges over transparency do not pick up
2386
+ // a dark halo as the two sides cross.
2387
+ vec4 nodBlend(vec4 a, vec4 b, float t, float d) {
2388
+ vec3 pa = nodToLinear(a.rgb) * a.a;
2389
+ vec3 pb = nodToLinear(b.rgb) * b.a;
2390
+ ${fade === "morph" ? `
2391
+ // MORPH — a per-pixel CHOICE, not a mix. d is a stable hash of the
2392
+ // output pixel, so each pixel flips from old to new at its own point
2393
+ // in t. At any instant almost every pixel is showing exactly ONE
2394
+ // side, and the two contents are never both legible at once.
2395
+ //
2396
+ // A uniform crossfade cannot do this: at t=0.5 it is by definition
2397
+ // 50% of each, which reads as a double exposure the moment the two
2398
+ // states differ much — a wide nav bar and a small card, say.
2399
+ float k = smoothstep(d - 0.12, d + 0.12, t);
2400
+ vec3 pm = pa * (1.0 - k) + pb * k;
2401
+ float al = a.a * (1.0 - k) + b.a * k;` : fade === "dissolve" ? `
2402
+ // DISSOLVE — a true cross-dissolve: alpha lerps between the two.
2403
+ // Correct, and the right choice when one side really should fade to
2404
+ // nothing. Wrong for a shape morph, because any region covered by
2405
+ // only ONE side is translucent for the whole transition and the
2406
+ // morph reads as "everything is disappearing".
2407
+ vec3 pm = mix(pa, pb, t);
2408
+ float al = mix(a.a, b.a, t);` : `
2409
+ // OPAQUE (default) — the colour crosses over without the shape
2410
+ // thinning out. The coverage-weighted lerp already gives that for
2411
+ // free, and it is worth seeing why:
2412
+ //
2413
+ // both sides cover a.a = b.a = 1 -> w = 1 at every t. Solid
2414
+ // throughout. No mid-fade.
2415
+ // only the old b.a = 0 -> w = 1-t. Releases.
2416
+ // only the new a.a = 0 -> w = t. Arrives.
2417
+ //
2418
+ // So one expression covers the union case AND each side's release,
2419
+ // linearly, with no curve to pop against.
2420
+ vec3 pm = pa * (1.0 - t) + pb * t;
2421
+ float al = a.a * (1.0 - t) + b.a * t;` }
2422
+ return vec4(nodToSRGB(pm / max(al, 1e-4)), al);
2423
+ }
2424
+
2425
+ // EVERY fetch in transition mode goes through here. A displace-stage op
2426
+ // that sampled u_tex directly — aberration does exactly that for its
2427
+ // per-channel taps — would show fringes from the new capture only, with
2428
+ // the old one silently absent from those channels.
2429
+ // Takes a position in DEVICE PX (sampleP space), not uv, because the two
2430
+ // captures no longer share a uv. Each is mapped through its own box and
2431
+ // masked to it, then blended.
2432
+ vec2 nodBoxUV(vec4 box, vec2 p) {
2433
+ return vec2((p.x - box.x) / max(box.z, 1.0),
2434
+ 1.0 - (p.y - box.y) / max(box.w, 1.0));
2435
+ }
2436
+ float nodInBox(vec2 uv) {
2437
+ return step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
2438
+ }
2439
+ vec4 nodSampleAt(vec2 p) {
2440
+ vec2 uo = nodBoxUV(u_boxOld, p);
2441
+ vec2 un = nodBoxUV(u_boxNew, p);
2442
+ vec4 o = texture2D(u_old, clamp(uo, 0.001, 0.999));
2443
+ // With newImage the new side is a capture of the ELEMENT, so both
2444
+ // sides can travel between their own rects and converge into one
2445
+ // shape. Without it the new side is the HOST capture, which can only
2446
+ // be mapped 1:1 — the destination then sits at its final place from
2447
+ // t=0 while the old shrinks toward it, and the result reads as two
2448
+ // things on screen at once rather than one thing morphing.
2449
+ vec4 n = texture2D(${transition && transition.newImage ? "u_newimg" : "u_tex"},
2450
+ clamp(un, 0.001, 0.999));
2451
+ o.a *= nodInBox(uo);
2452
+ n.a *= nodInBox(un);
2453
+ ${oldSideCode}${newSideCode}
2454
+ // The threshold each pixel flips at. Mostly a COHERENT ramp down the
2455
+ // box with a little grain on top — pure white noise chooses correctly
2456
+ // (never two legible images at once) but reads as television static
2457
+ // rather than as one thing becoming another. The ramp makes it a
2458
+ // wipe; the grain keeps the edge from looking like a ruler.
2459
+ float grain = fract(sin(dot(floor(p / 3.0), vec2(12.9898, 78.233))) * 43758.5453);
2460
+ float ramp = clamp(un.y, 0.0, 1.0);
2461
+ float d = ramp * 0.82 + grain * 0.18;
2462
+ return nodBlend(o, n, u_t, d);
2463
+ }` : ""}
1534
2464
  void main() {
1535
- vec2 frag = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
2465
+ vec2 fc = ${fragCoord};
2466
+ vec2 frag = vec2(fc.x, u_res.y - fc.y);
1536
2467
  ${fieldDecls}
1537
2468
  ${field}
1538
2469
  vec2 warped = frag;
@@ -1542,9 +2473,13 @@ ${warp}
1542
2473
  ${cell}
1543
2474
  vec2 sampleP = warped;
1544
2475
  vec2 chroma = vec2(0.0);
1545
- ${displace}
2476
+ ${displace}${probe ? probeTail : `
2477
+ ${transition ? `
2478
+ // uv stays defined, as the NEW box's, for ops that reference it.
2479
+ vec2 uv = nodBoxUV(u_boxNew, sampleP);
2480
+ vec4 tex = nodSampleAt(sampleP);` : `
1546
2481
  vec2 uv = vec2(sampleP.x / u_res.x, 1.0 - sampleP.y / u_res.y);
1547
- vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));
2482
+ vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));`}
1548
2483
  vec3 col = tex.rgb;${chromaFetch}
1549
2484
  vec3 edgeCol = vec3(1.0);
1550
2485
  float edgeCov = 0.0;
@@ -1559,7 +2494,7 @@ ${defaultSeam}
1559
2494
  float outA = ovA + baseA * (1.0 - ovA);
1560
2495
  vec3 outRGB = (ovCol * ovA + baseRGB * baseA * (1.0 - ovA)) / max(outA, 1e-4);
1561
2496
  gl_FragColor = vec4(outRGB, outA);
1562
- }`;
2497
+ }`}`;
1563
2498
  }
1564
2499
 
1565
2500
  // ── Snapshot backend: DOM subtree -> SVG foreignObject -> texture ────
@@ -1574,19 +2509,97 @@ ${defaultSeam}
1574
2509
  // computes, on a clone, so the snapshot matches the live layout.
1575
2510
  const VIEWPORT_UNIT = /\d(?:vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh)\b/i;
1576
2511
 
1577
- function freezeViewportUnits(original, clone) {
2512
+ // Inheritable text properties. A foreignObject rendered from a data: URI
2513
+ // carries NO page stylesheet, and a clone carries only inline styles — so
2514
+ // anything a rule or an ancestor supplied is gone. Nodality's own
2515
+ // components style inline and survive; hand-written markup styled by CSS
2516
+ // captures as default black serif on transparent, which is what made the
2517
+ // demo panels render dark text on a dark panel.
2518
+ //
2519
+ // Frozen onto the clone only where an element's computed value DIFFERS
2520
+ // from its parent's, so inheritance still does the work and the
2521
+ // serialized string does not balloon on a large subtree.
2522
+ const INHERITED = [
2523
+ "color", "font-family", "font-size", "font-weight", "font-style",
2524
+ "line-height", "letter-spacing", "text-align", "text-transform",
2525
+ "text-decoration-color", "white-space", "word-break",
2526
+ ];
2527
+
2528
+ // Painted, NON-inherited properties — see part 3 of freezeStyles. Layout
2529
+ // properties are deliberately absent: freezing width or padding would
2530
+ // relayout the clone and move the glyphs off the real text.
2531
+ const PAINTED = [
2532
+ "background-color", "background-image", "background-size",
2533
+ "background-position", "background-repeat", "background-clip",
2534
+ "border-radius", "box-shadow", "opacity", "outline-color",
2535
+ ];
2536
+
2537
+ // Initial values, skipped so a page of plain elements does not gain ten
2538
+ // no-op declarations per node.
2539
+ const PAINT_INITIAL = new Set([
2540
+ "none", "rgba(0, 0, 0, 0)", "transparent", "0px", "auto", "1",
2541
+ "repeat", "0% 0%", "border-box", "0% 0% / auto repeat scroll padding-box border-box",
2542
+ ]);
2543
+
2544
+ function freezeStyles(original, clone) {
1578
2545
  const origs = [original, ...original.querySelectorAll("*")];
1579
2546
  const clones = [clone, ...clone.querySelectorAll("*")];
1580
2547
  for (let i = 0; i < origs.length && i < clones.length; i++) {
1581
2548
  const inline = clones[i].style;
1582
- if (!inline || inline.length === 0) continue;
1583
- let computed = null;
1584
- // Iterate a snapshot of the names: writing to style mutates the list.
1585
- for (const prop of Array.from(inline)) {
1586
- if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
1587
- computed = computed || window.getComputedStyle(origs[i]);
1588
- const px = computed.getPropertyValue(prop);
1589
- if (px) inline.setProperty(prop, px, inline.getPropertyPriority(prop));
2549
+ if (!inline) continue;
2550
+
2551
+ // 1. Viewport units resolve against the SVG's own size inside a
2552
+ // foreignObject, not the browser viewport, so `calc(1.6rem +
2553
+ // 5vw)` renders at a different size than it does on the page.
2554
+ // The glyphs then sit somewhere the DOM text does not, which
2555
+ // is what makes a selection highlight look offset.
2556
+ if (inline.length > 0) {
2557
+ let computed = null;
2558
+ // Iterate a snapshot of the names: writing to style mutates the list.
2559
+ for (const prop of Array.from(inline)) {
2560
+ if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
2561
+ computed = computed || window.getComputedStyle(origs[i]);
2562
+ const px = computed.getPropertyValue(prop);
2563
+ if (px) inline.setProperty(prop, px, inline.getPropertyPriority(prop));
2564
+ }
2565
+ }
2566
+
2567
+ // 2. Inherited text styling, which the clone would otherwise lose.
2568
+ const cs = window.getComputedStyle(origs[i]);
2569
+ const parent = origs[i].parentElement;
2570
+ const ps = i === 0 || !parent ? null : window.getComputedStyle(parent);
2571
+ for (const prop of INHERITED) {
2572
+ const v = cs.getPropertyValue(prop);
2573
+ if (!v) continue;
2574
+ // The root always states its value — there is no ancestor
2575
+ // inside the foreignObject to inherit from.
2576
+ if (ps && ps.getPropertyValue(prop) === v) continue;
2577
+ if (inline.getPropertyValue(prop)) continue; // caller was explicit
2578
+ inline.setProperty(prop, v);
2579
+ }
2580
+
2581
+ // 3. Painted, NON-inherited properties. Inheritance cannot rescue
2582
+ // these: `.card { background: #e8eef5 }` is simply absent from
2583
+ // the clone, so the element captures fully transparent.
2584
+ //
2585
+ // The symptom is easy to misread. In a transition the two
2586
+ // captures crossfade against each other, and with backgrounds
2587
+ // missing both sides are transparent everywhere except their
2588
+ // glyphs — so the morph looks like it fades away toward the
2589
+ // middle and "disappears", when every pixel is exactly where
2590
+ // it belongs and merely has nothing opaque behind it.
2591
+ //
2592
+ // Restricted to properties that do not affect LAYOUT. Freezing
2593
+ // width, padding or border-width would relayout the clone and
2594
+ // move the glyphs away from where the real text sits — the
2595
+ // very bug this function was written to prevent.
2596
+ for (const prop of PAINTED) {
2597
+ if (inline.getPropertyValue(prop)) continue; // caller was explicit
2598
+ const v = cs.getPropertyValue(prop);
2599
+ // Skip initial values, or every node gains a handful of
2600
+ // no-op declarations and the serialized string balloons.
2601
+ if (!v || PAINT_INITIAL.has(v)) continue;
2602
+ inline.setProperty(prop, v);
1590
2603
  }
1591
2604
  }
1592
2605
  return clone;
@@ -1697,7 +2710,7 @@ function snapshotToImage(el, w, h, dpr) {
1697
2710
 
1698
2711
  return new Promise((resolve, reject) => {
1699
2712
  const serialized = new XMLSerializer()
1700
- .serializeToString(freezeViewportUnits(el, el.cloneNode(true)));
2713
+ .serializeToString(freezeStyles(el, el.cloneNode(true)));
1701
2714
  const svg =
1702
2715
  `<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
1703
2716
  `<foreignObject width="100%" height="100%">` +
@@ -1826,17 +2839,101 @@ function wrapReplacedHost(el) {
1826
2839
 
1827
2840
  // ── Pipeline runner ──────────────────────────────────────────────────
1828
2841
 
1829
- function applyRasterPipeline(el, rasterNodes) {
2842
+ // Fired on `document` whenever a pipeline param changes through setParam,
2843
+ // so any UI showing that data — the inspector, a code preview, an editor —
2844
+ // stays in step without polling or knowing about the others.
2845
+ // detail: { pipeline, el, index, key, value, prev, how }
2846
+ // `how` is "uniform" or "rebuild", the same value setParam returns.
2847
+ const RASTER_PARAM_EVENT = "nodality:raster-param";
2848
+
2849
+ // ── Live pipelines (phase H3) ────────────────────────────────────────
2850
+ // Every attached pipeline, so a dev tool can find what is running without
2851
+ // the page having to hand it over. A Set, not a WeakSet: enumerating is
2852
+ // the entire point, and destroy() removes deterministically.
2853
+ const ACTIVE = new Set();
2854
+
2855
+ /** Every raster pipeline currently attached, in attach order. */
2856
+ function activeRasterPipelines() {
2857
+ return [...ACTIVE];
2858
+ }
2859
+
2860
+ // Params that are compiled into the shader rather than uploaded as a
2861
+ // uniform, per op. Measured from which of code()/uniforms() reads them —
2862
+ // see the table in HOUDINI-DECISIONS. `masked`, `op` and `live` are
2863
+ // structural for every op: they change the emitted code or the backend.
2864
+ const ALWAYS_STRUCTURAL = ["op", "masked", "live"];
2865
+
2866
+ /**
2867
+ * Does changing `key` on an op of this type require a new shader?
2868
+ *
2869
+ * Unknown keys rebuild. That is deliberately the pessimistic default: a
2870
+ * missed structural param renders the wrong thing with no error, while an
2871
+ * unnecessary rebuild only costs a frame.
2872
+ */
2873
+ function isStructuralChange(op, key, next, prev) {
2874
+ if (ALWAYS_STRUCTURAL.includes(key)) return true;
2875
+ const def = REGISTRY[op];
2876
+ if (!def) return true;
2877
+ if ((def.structural || []).includes(key)) return true;
2878
+ // A param that only branches the shader when it crosses zero —
2879
+ // hexalize's `lift` picks the cheap path at 0 and the seven-neighbour
2880
+ // probe otherwise, so 0 -> 0.3 needs a rebuild but 0.3 -> 0.5 does not.
2881
+ if ((def.structuralOnToggle || []).includes(key)) return !next !== !prev;
2882
+ // Known uniform? Then it is live.
2883
+ if (def.uniforms) {
2884
+ try { if (key in def.uniforms(Object.assign({ op }, { [key]: next }), 1)) return false; }
2885
+ catch (e) { /* fall through to the pessimistic default */ }
2886
+ }
2887
+ return true;
2888
+ }
2889
+
2890
+ /**
2891
+ * @param {HTMLElement} el
2892
+ * @param {Array} rasterNodes
2893
+ * @param {object} [opts]
2894
+ * @param {object} [opts.transition] phase T2 — run in transition mode.
2895
+ * `{ oldImage, oldRect, newRect }` where oldImage is an already
2896
+ * captured Image/canvas of the OLD element (it has to be captured
2897
+ * before the DOM swap, because by the time the morph runs that subtree
2898
+ * is gone), and the rects are CSS px relative to `el`.
2899
+ */
2900
+ // One stylesheet for the whole library: while a host's ink is hidden, a
2901
+ // selection inside it paints its glyphs in the colour they had before the
2902
+ // canvas took over. Unselected text stays transparent (the canvas is
2903
+ // drawing it), so what the user sees is exactly the selected run appearing
2904
+ // in place — rather than a blank rectangle over an invisible one.
2905
+ let selectionStyleAdded = false;
2906
+ function ensureSelectionStyle() {
2907
+ if (selectionStyleAdded || typeof document === "undefined") return;
2908
+ selectionStyleAdded = true;
2909
+ const s = document.createElement("style");
2910
+ s.setAttribute("data-nodality", "selection");
2911
+ s.textContent =
2912
+ "[data-nodality-ink]::selection,[data-nodality-ink] *::selection{" +
2913
+ "color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}" +
2914
+ "[data-nodality-ink]::-moz-selection,[data-nodality-ink] *::-moz-selection{" +
2915
+ "color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}";
2916
+ (document.head || document.documentElement).appendChild(s);
2917
+ }
2918
+
2919
+ function applyRasterPipeline(el, rasterNodes, opts) {
2920
+ // Kept before flattening: rebuild() needs the tree, not the flat list.
2921
+ const sourceNodes = rasterNodes;
2922
+ const transition = (opts && opts.transition) || null;
1830
2923
  // Hard guards — every early-out is silent by design so that jsdom
1831
2924
  // prerender, old browsers and reduced-motion users get the plain
1832
2925
  // page untouched.
1833
2926
  if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
1834
- if (!rasterNodes || rasterNodes.length === 0) return null;
2927
+ // An empty chain is nothing to do — EXCEPT in transition mode, where
2928
+ // the crossfade between the two captures IS the effect and ops are
2929
+ // only decoration on top of it.
2930
+ if ((!rasterNodes || rasterNodes.length === 0) && !transition) return null;
2931
+ if (!rasterNodes) rasterNodes = [];
1835
2932
  // Flatten any switch nodes down to the chain this device actually
1836
2933
  // gets, before anything reads the list.
1837
2934
  if (rasterNodes.some((n) => n && n.op === "switch")) {
1838
2935
  rasterNodes = resolveSwitches(rasterNodes, 0);
1839
- if (rasterNodes.length === 0) return null;
2936
+ if (rasterNodes.length === 0 && !transition) return null;
1840
2937
  }
1841
2938
  // Flatten merge sub-chains into the linear list that owns the
1842
2939
  // uniform slots. `tree` keeps the branch structure for the shader
@@ -1888,7 +2985,14 @@ function applyRasterPipeline(el, rasterNodes) {
1888
2985
  const rect = measure();
1889
2986
  if (rect.width < 2 || rect.height < 2) return null;
1890
2987
 
1891
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
2988
+ // Render resolution. The cap exists because every op runs per output
2989
+ // pixel, so cost is quadratic in it — but capping at 2 also means a 3x
2990
+ // phone or a scaled 4K desktop renders the effect at LOWER density than
2991
+ // the text beside it, and the canvas reads as soft next to real DOM.
2992
+ // Default to the display's own density up to 3, and let a caller that
2993
+ // knows its budget say otherwise.
2994
+ const dpr = Math.max(1, Math.min(
2995
+ opts && opts.resolution ? opts.resolution : (window.devicePixelRatio || 1), 3));
1892
2996
  const isOverlay = rasterNodes.some((n) => (REGISTRY[n.op] || {}).overlay);
1893
2997
  // Stacking: a pipeline may mix in-place ops (hexalize/offset/duotone/
1894
2998
  // edges) with an overlay op (blobs). The single shader composites them
@@ -1896,7 +3000,14 @@ function applyRasterPipeline(el, rasterNodes) {
1896
3000
  // chain leaves the host visible (the lens refracts untouched content).
1897
3001
  // The moment an in-place op joins, the canvas fully carries the look,
1898
3002
  // so the host must be hidden or the original content ghosts through.
1899
- const pureOverlay = rasterNodes.every((n) => (REGISTRY[n.op] || {}).overlay);
3003
+ // `.every()` on an EMPTY array is vacuously true, which used to be
3004
+ // unreachable — a chain with no ops returned null long before here.
3005
+ // Transition mode made it reachable, and an empty transition chain
3006
+ // was classified a pure overlay: the canvas composited over untouched
3007
+ // DOM instead of replacing it, so the same chain rendered differently
3008
+ // with and without a zero-strength op in it.
3009
+ const pureOverlay = rasterNodes.length > 0 && !transition &&
3010
+ rasterNodes.every((n) => (REGISTRY[n.op] || {}).overlay);
1900
3011
  // Live-first (HTML-in-Canvas). The live backend captures content that
1901
3012
  // lives INSIDE the canvas (texElementImage2D over the restructured
1902
3013
  // subtree) — the same model canvasUI's bubble uses: content in the
@@ -1907,7 +3018,29 @@ function applyRasterPipeline(el, rasterNodes) {
1907
3018
  // subtree to sample; that case stays snapshot). A combined chain
1908
3019
  // (blobs + hexalize/offset/…) hides the host and therefore runs live,
1909
3020
  // lens included. Opt out per pipeline with `live: false` on any node.
1910
- const wantLive = !pureOverlay && !rasterNodes.some((n) => n.live === false);
3021
+ // A TRANSITION never wants the live backend, and the reason is not a
3022
+ // preference — it is that live costs interactivity and buys nothing
3023
+ // here.
3024
+ //
3025
+ // Buys nothing: a morph samples u_old and u_newimg, two frozen
3026
+ // captures. The live upload of the host is never read (see
3027
+ // nodSampleAt), so restructuring the DOM into the canvas produces a
3028
+ // texture nobody looks at.
3029
+ //
3030
+ // Costs interactivity: the live path MOVES the host's children into
3031
+ // the canvas, where they become canvas fallback content — present in
3032
+ // the accessibility tree but not hit-testable the way ordinary DOM
3033
+ // is. Every control inside the morphed element then stops responding
3034
+ // to the pointer, which is what "the back button is not clickable"
3035
+ // is. It only reproduces on a browser with the HTML-in-Canvas API,
3036
+ // so a snapshot-only run says everything is fine.
3037
+ //
3038
+ // `transition.live: true` overrides this deliberately, for comparing
3039
+ // the two backends side by side. Expect the morph to LOOK the same and
3040
+ // stop responding to the pointer — that is the trade being shown.
3041
+ const forceLive = !!(transition && transition.live === true);
3042
+ const wantLive = !pureOverlay && (!transition || forceLive) &&
3043
+ !rasterNodes.some((n) => n.live === false);
1911
3044
  const apiAvailable =
1912
3045
  (typeof WebGL2RenderingContext !== "undefined" &&
1913
3046
  "texElementImage2D" in WebGL2RenderingContext.prototype) ||
@@ -1948,6 +3081,9 @@ function applyRasterPipeline(el, rasterNodes) {
1948
3081
  canvas.setAttribute("data-nodality-raster", live ? "live" : "snapshot");
1949
3082
  console.info("[nodality] raster backend:", live ? "html-in-canvas (live)" : "snapshot");
1950
3083
 
3084
+ // In live mode the canvas fills the host's CONTENT box, not its border
3085
+ // box (it is a child of the host). Filled in below when live attaches.
3086
+ const liveBox = { width: rect.width, height: rect.height };
1951
3087
  let sourceEl = el; // element uploaded as the texture in live mode
1952
3088
  if (live) {
1953
3089
  // HTML-in-Canvas: the subtree becomes canvas children (wrapped in
@@ -1976,9 +3112,42 @@ function applyRasterPipeline(el, rasterNodes) {
1976
3112
  // mode the children never leave the host.
1977
3113
  const hostCS = getComputedStyle(el);
1978
3114
  const hostIsFlex = hostCS.display === "flex" || hostCS.display === "inline-flex";
3115
+ // GRID too, and for a sharper reason than flex.
3116
+ //
3117
+ // Dropping a flex host's layout misplaced its children; dropping a
3118
+ // GRID host's layout changes the box SIZE. Columns collapse, the
3119
+ // children stack vertically, and they overflow the fixed-height
3120
+ // wrapper — at which point measure()'s scrollHeight fallback (the
3121
+ // host's own rect is collapsed in live mode) starts counting the
3122
+ // overflowing canvas, which is a child of the thing being measured.
3123
+ // Canvas grows -> host grows -> ResizeObserver -> repeat. A
3124
+ // 98px-tall two-column panel reached 4014px before this line.
3125
+ const hostIsGrid = hostCS.display === "grid" || hostCS.display === "inline-grid";
3126
+
3127
+ // The canvas is a CHILD of the host, so it lives in the host's
3128
+ // CONTENT box — but `rect` is the border box. Sizing the canvas to
3129
+ // `rect` therefore overflows by the padding on both axes and makes
3130
+ // the host padding taller than it started, every time. Measure the
3131
+ // content box once, here, and use it for both the canvas and the
3132
+ // subtree; the host keeps its own padding and nothing is applied
3133
+ // twice.
3134
+ const px = (v) => parseFloat(v) || 0;
3135
+ const padX = px(hostCS.paddingLeft) + px(hostCS.paddingRight);
3136
+ const padY = px(hostCS.paddingTop) + px(hostCS.paddingBottom);
3137
+ liveBox.width = Math.max(2, rect.width - padX);
3138
+ liveBox.height = Math.max(2, rect.height - padY);
3139
+
1979
3140
  wrap.style.cssText =
1980
- `display:${hostIsFlex ? "flex" : "block"};` +
1981
- `width:${rect.width}px;height:${rect.height}px;`;
3141
+ `display:${hostIsFlex || hostIsGrid ? hostCS.display : "block"};` +
3142
+ `width:${liveBox.width}px;height:${liveBox.height}px;`;
3143
+ if (hostIsGrid) {
3144
+ wrap.style.gridTemplateColumns = hostCS.gridTemplateColumns;
3145
+ wrap.style.gridTemplateRows = hostCS.gridTemplateRows;
3146
+ wrap.style.gridTemplateAreas = hostCS.gridTemplateAreas;
3147
+ wrap.style.gap = hostCS.gap;
3148
+ wrap.style.alignItems = hostCS.alignItems;
3149
+ wrap.style.justifyItems = hostCS.justifyItems;
3150
+ }
1982
3151
  if (hostIsFlex) {
1983
3152
  wrap.style.flexDirection = hostCS.flexDirection;
1984
3153
  wrap.style.alignItems = hostCS.alignItems;
@@ -1991,7 +3160,7 @@ function applyRasterPipeline(el, rasterNodes) {
1991
3160
  // The host collapses once its children move into the canvas, so
1992
3161
  // the canvas itself carries the box in normal flow (not overlay).
1993
3162
  canvas.style.cssText =
1994
- `display:block;width:${rect.width}px;height:${rect.height}px;`;
3163
+ `display:block;width:${liveBox.width}px;height:${liveBox.height}px;`;
1995
3164
  } else {
1996
3165
  // Snapshot mode: purely visual overlay.
1997
3166
  canvas.style.cssText =
@@ -2026,7 +3195,8 @@ function applyRasterPipeline(el, rasterNodes) {
2026
3195
  };
2027
3196
  const prog = gl.createProgram();
2028
3197
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
2029
- gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterTree, rasterNodes)));
3198
+ gl.attachShader(prog, compile(gl.FRAGMENT_SHADER,
3199
+ buildFragmentShader(rasterTree, rasterNodes, false, transition)));
2030
3200
  // Pin "a" to location 0 so a field-simulation program (which shares
2031
3201
  // this vertex buffer and attribute array) can be swapped in without
2032
3202
  // respecifying the pointer.
@@ -2046,15 +3216,46 @@ function applyRasterPipeline(el, rasterNodes) {
2046
3216
  else if (kind === "3fv") gl.uniform3fv(loc, value);
2047
3217
  };
2048
3218
 
2049
- // Static uniforms from the node data.
2050
- rasterNodes.forEach((node, i) => {
2051
- const def = REGISTRY[node.op];
2052
- if (!def || !def.uniforms) return;
2053
- const us = def.uniforms(node, dpr);
2054
- for (const key in us) {
2055
- setUniform(U(`u${i}_${key}`), us[key][0], us[key][1]);
3219
+ // Static uniforms from the node data. Uploaded once per PROGRAM
3220
+ // rather than once per pipeline: phase I1's probe is a second program
3221
+ // running the same chain, and without these its op uniforms would all
3222
+ // read 0 — a `size` of 0 divides to infinity and the whole coordinate
3223
+ // computation becomes NaN, which readback reports as a confident 0.
3224
+ // Phase T1. Transition progress. Static uniforms are resolved AT this
3225
+ // value, so a keyframed param is correct from the first frame rather
3226
+ // than jumping once something scrubs.
3227
+ let progress = 0;
3228
+ const keyframed = rasterNodes
3229
+ .map((node, i) => ({ node, i }))
3230
+ .filter(({ node }) => hasKeyframes(node));
3231
+
3232
+ const applyStaticUniforms = (Uq, only) => {
3233
+ rasterNodes.forEach((node, i) => {
3234
+ if (only && !only.has(i)) return;
3235
+ const def = REGISTRY[node.op];
3236
+ if (!def || !def.uniforms) return;
3237
+ const us = def.uniforms(resolveNode(node, progress), dpr);
3238
+ for (const key in us) {
3239
+ setUniform(Uq(`u${i}_${key}`), us[key][0], us[key][1]);
3240
+ }
3241
+ });
3242
+ };
3243
+ applyStaticUniforms(U);
3244
+
3245
+ // Re-upload only the keyframed nodes. Cheap enough to run per frame
3246
+ // while a transition is scrubbing, and a no-op for every chain that
3247
+ // has no keyframes — which is all of them until someone opts in.
3248
+ const kfIndices = new Set(keyframed.map((k) => k.i));
3249
+ const uploadKeyframes = () => {
3250
+ if (!keyframed.length) return;
3251
+ gl.useProgram(prog);
3252
+ applyStaticUniforms(U, kfIndices);
3253
+ if (probe && !probe.broken) {
3254
+ gl.useProgram(probe.prog);
3255
+ applyStaticUniforms(probe.U, kfIndices);
3256
+ gl.useProgram(prog);
2056
3257
  }
2057
- });
3258
+ };
2058
3259
 
2059
3260
  // Ops with a CPU simulation (e.g. blobs) get per-frame state and
2060
3261
  // feed dynamic uniforms each draw (see the render loop below).
@@ -2262,6 +3463,52 @@ function applyRasterPipeline(el, rasterNodes) {
2262
3463
  const finishUpload = () => { if (needMips) gl.generateMipmap(gl.TEXTURE_2D); };
2263
3464
 
2264
3465
  let textureReady = false;
3466
+
3467
+ // Phase T2. The frozen capture of the element being transitioned
3468
+ // FROM. Uploaded once — it is a still by definition, which is also
3469
+ // why transitions need no live-capture support and therefore work in
3470
+ // every browser that has WebGL, not just those in the origin trial.
3471
+ let oldTex = null;
3472
+ let newTex = null;
3473
+ // NOTE: the new element's ink is NOT hidden here. An earlier attempt
3474
+ // did it on the next animation frame and raced the capture — the
3475
+ // texture was taken from an already-transparent subtree, so the new
3476
+ // side of every morph was blank. The existing post-capture path
3477
+ // (`if (!pureOverlay) hideHostInk()`) runs at the right moment, and
3478
+ // transition mode is never pureOverlay; setProgress governs it from
3479
+ // then on.
3480
+ if (transition && transition.oldImage) {
3481
+ oldTex = gl.createTexture();
3482
+ gl.bindTexture(gl.TEXTURE_2D, oldTex);
3483
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3484
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3485
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3486
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3487
+ try {
3488
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
3489
+ gl.UNSIGNED_BYTE, transition.oldImage);
3490
+ } catch (e) {
3491
+ console.warn("[nodality] transition: old capture upload failed:", e);
3492
+ gl.deleteTexture(oldTex);
3493
+ oldTex = null;
3494
+ }
3495
+ }
3496
+ if (transition && transition.newImage) {
3497
+ newTex = gl.createTexture();
3498
+ gl.bindTexture(gl.TEXTURE_2D, newTex);
3499
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3500
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3501
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3502
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3503
+ try {
3504
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
3505
+ gl.UNSIGNED_BYTE, transition.newImage);
3506
+ } catch (e) {
3507
+ console.warn("[nodality] transition: new capture upload failed:", e);
3508
+ gl.deleteTexture(newTex);
3509
+ newTex = null;
3510
+ }
3511
+ }
2265
3512
  let destroyed = false;
2266
3513
  let mode = live ? "live" : "snapshot";
2267
3514
 
@@ -2305,12 +3552,31 @@ function applyRasterPipeline(el, rasterNodes) {
2305
3552
 
2306
3553
  const hideHostInk = () => {
2307
3554
  if (inkSaved) return;
3555
+ ensureSelectionStyle();
2308
3556
  inkSaved = [];
2309
- for (const node of [el, ...el.querySelectorAll("*")]) {
2310
- if (node === canvas || node.tagName === "CANVAS") continue;
2311
- const saved = { op: node.style.getPropertyValue("opacity") };
3557
+ el.setAttribute("data-nodality-ink", "");
3558
+ const nodes = [el, ...el.querySelectorAll("*")]
3559
+ .filter((n) => n !== canvas && n.tagName !== "CANVAS");
3560
+ // Selection has to stay LEGIBLE while the ink is transparent. The
3561
+ // host is deliberately still selectable (see above), but with
3562
+ // color:transparent a drag paints the selection rectangle over
3563
+ // invisible glyphs — a blank block that hides the effect instead
3564
+ // of highlighting anything. Stash each node's real colour so the
3565
+ // ::selection rule can repaint just the selected run.
3566
+ //
3567
+ // Read EVERY colour before writing ANY, in two passes. Colour is
3568
+ // inherited, so transparentising a parent inside a single loop
3569
+ // makes each later child read back the already-hidden value —
3570
+ // which stores transparent as the "real" colour and restores
3571
+ // nothing.
3572
+ const realInk = nodes.map((n) => getComputedStyle(n).color);
3573
+ for (let i = 0; i < nodes.length; i++) {
3574
+ const node = nodes[i];
3575
+ const saved = { op: node.style.getPropertyValue("opacity"),
3576
+ ink: node.style.getPropertyValue("--nod-ink") };
2312
3577
  for (const p in INK) saved[p] = node.style.getPropertyValue(p);
2313
3578
  inkSaved.push([node, saved]);
3579
+ node.style.setProperty("--nod-ink", realInk[i]);
2314
3580
  for (const p in INK) node.style.setProperty(p, INK[p], "important");
2315
3581
  // Replaced content has no "ink" property to neutralise.
2316
3582
  if (node !== el && node.matches && node.matches(REPLACED)) {
@@ -2325,6 +3591,7 @@ function applyRasterPipeline(el, rasterNodes) {
2325
3591
 
2326
3592
  const showHostInk = () => {
2327
3593
  el.style.visibility = "";
3594
+ el.removeAttribute("data-nodality-ink");
2328
3595
  if (!inkSaved) return;
2329
3596
  for (const [node, saved] of inkSaved) {
2330
3597
  for (const p in INK) {
@@ -2333,12 +3600,76 @@ function applyRasterPipeline(el, rasterNodes) {
2333
3600
  }
2334
3601
  if (saved.op) node.style.setProperty("opacity", saved.op);
2335
3602
  else node.style.removeProperty("opacity");
3603
+ if (saved.ink) node.style.setProperty("--nod-ink", saved.ink);
3604
+ else node.style.removeProperty("--nod-ink");
2336
3605
  }
2337
3606
  inkSaved = null;
2338
3607
  el.style.isolation = "";
2339
3608
  canvas.style.zIndex = "";
2340
3609
  };
2341
3610
 
3611
+ // Phase T2. WHO presents: the canvas while a transition is running,
3612
+ // the real DOM once it completes. They are alternatives, never both.
3613
+ //
3614
+ // This has to be one function called from everywhere, because the
3615
+ // snapshot path hides ink on EVERY capture completion — and a capture
3616
+ // can land long after the transition has finished (a resize, a late
3617
+ // image load, a refresh()). When that happened after t reached 1 the
3618
+ // ink was hidden again while the canvas was already down, and the
3619
+ // element vanished. It only reproduced on a real browser: headless
3620
+ // finishes its single capture before anything can scrub.
3621
+ //
3622
+ // Returns true when it took ownership, so callers know to stand down.
3623
+ // Re-asserted EVERY FRAME rather than only on the events that change
3624
+ // it, because at least three other subsystems write the same state
3625
+ // for their own reasons and the last writer wins:
3626
+ //
3627
+ // - snapshotCapture() calls showHostInk() before serialising, so
3628
+ // the capture sees real ink rather than the transparent version;
3629
+ // - its completion hides the ink again;
3630
+ // - the live -> snapshot fallback rewrites canvas.style.cssText
3631
+ // wholesale, discarding `visibility: hidden` along with it.
3632
+ //
3633
+ // Any of those can land after a transition completes and leave the
3634
+ // element hidden behind an already-hidden canvas, which is exactly
3635
+ // the "it disappears at t=1" report. Ordering fixes kept missing a
3636
+ // path; a cheap idempotent re-assert cannot. Two property reads per
3637
+ // frame, and only in transition mode.
3638
+ const syncTransitionView = () => {
3639
+ if (!transition) return false;
3640
+ const done = progress >= 1;
3641
+ // Standing the canvas down at t=1 assumes it OVERLAYS the content,
3642
+ // so hiding it reveals the real element underneath. That is true
3643
+ // of the snapshot backend, but the live backend MOVES the
3644
+ // element's children into the canvas (canvas.appendChild(wrap)) —
3645
+ // there the canvas is where the content lives, and hiding it hides
3646
+ // the very thing being handed over to.
3647
+ //
3648
+ // The test is `sourceEl !== el`, not `mode === "live"`. A live
3649
+ // pipeline only restructures when it actually needs the live
3650
+ // upload; a transition draws from the two frozen captures instead,
3651
+ // so it stays an overlay even on a live-capable browser. Keying
3652
+ // off the mode suppressed ink suppression on every such browser.
3653
+ const hostsContent = sourceEl !== el;
3654
+ // A morph owns the screen strictly BETWEEN its endpoints. At t=1
3655
+ // the new element takes over; opting in with `standDownAtStart`
3656
+ // makes t=0 symmetric, so the OLD element presents there — real,
3657
+ // clickable, selectable — instead of a picture of itself. Opt-in
3658
+ // because the library cannot supply the old element: it belongs to
3659
+ // the caller, who must put it back. Without it, t=0 keeps showing
3660
+ // the old capture, which is the right default for a morph that is
3661
+ // about to run.
3662
+ const atStart = transition.standDownAtStart && progress <= 0;
3663
+ const wantVis = (done || atStart) && !hostsContent ? "hidden" : "visible";
3664
+ if (canvas.style.visibility !== wantVis) canvas.style.visibility = wantVis;
3665
+ if (done) {
3666
+ if (inkSaved) showHostInk();
3667
+ } else if (!inkSaved) {
3668
+ hideHostInk();
3669
+ }
3670
+ return true;
3671
+ };
3672
+
2342
3673
  const snapshotCapture = () => {
2343
3674
  // Overlay + image content: use the image directly as the texture.
2344
3675
  if (isOverlay) {
@@ -2374,8 +3705,12 @@ function applyRasterPipeline(el, rasterNodes) {
2374
3705
  // A pure-overlay chain leaves the host alone; the lens
2375
3706
  // floats over untouched content. Otherwise the canvas
2376
3707
  // carries the look and the host's own ink is suppressed.
2377
- if (!pureOverlay) hideHostInk();
2378
- canvas.style.visibility = "visible";
3708
+ // In transition mode progress decides this, not the
3709
+ // capture — see syncTransitionView.
3710
+ if (!syncTransitionView()) {
3711
+ if (!pureOverlay) hideHostInk();
3712
+ canvas.style.visibility = "visible";
3713
+ }
2379
3714
  })
2380
3715
  .catch((e) => {
2381
3716
  showHostInk();
@@ -2519,9 +3854,420 @@ function applyRasterPipeline(el, rasterNodes) {
2519
3854
 
2520
3855
  // Render loop — paused while off-screen.
2521
3856
  let raf = 0;
3857
+ // The last frame's uniform values, so the phase-I1 probe pass can
3858
+ // reproduce that exact frame on the probe program. Null until the
3859
+ // first draw — sourceAt() reports "not ready" rather than guessing.
3860
+ let lastSnap = null;
3861
+
3862
+ /** Write a frame snapshot to whichever program `Uq` resolves against. */
3863
+ const applyUniforms = (Uq, s) => {
3864
+ gl.uniform2f(Uq("u_res"), s.res[0], s.res[1]);
3865
+ gl.uniform2f(Uq("u_mouse"), s.mouse[0], s.mouse[1]);
3866
+ gl.uniform1f(Uq("u_time"), s.time);
3867
+ gl.uniform1f(Uq("u_t"), s.t);
3868
+ for (const d of s.drivers) {
3869
+ gl.uniform2f(Uq(`u${d.i}_dpos`), d.x, d.y);
3870
+ gl.uniform1f(Uq(`u${d.i}_damt`), d.amt);
3871
+ }
3872
+ for (const d of s.dyn) {
3873
+ for (const u of d.ups) setUniform(Uq(`u${d.i}_${u.name}`), u.kind, u.value);
3874
+ }
3875
+ // Content on unit 0, then each solver's named result textures
3876
+ // (velocity, dye, ...) on units above it.
3877
+ gl.activeTexture(gl.TEXTURE0);
3878
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3879
+ gl.uniform1i(Uq("u_tex"), 0);
3880
+ let unit = 1;
3881
+ if (transition && oldTex) {
3882
+ // Phase T2. The frozen old capture, and the box both sides
3883
+ // are drawn into — lerped by progress, in device px.
3884
+ gl.activeTexture(gl.TEXTURE0 + unit);
3885
+ gl.bindTexture(gl.TEXTURE_2D, oldTex);
3886
+ gl.uniform1i(Uq("u_old"), unit);
3887
+ unit++;
3888
+ // Each side interpolates in its own rect. `oldTo` is where
3889
+ // the outgoing element travels to (default: the new rect, so
3890
+ // they converge), `newFrom` where the incoming one starts
3891
+ // from (default: the old rect). Supplying both is how you get
3892
+ // "old exits left while new enters from the right".
3893
+ const t = s.t;
3894
+ const lerpBox = (from, to) => [
3895
+ (from.x + (to.x - from.x) * t) * dpr,
3896
+ (from.y + (to.y - from.y) * t) * dpr,
3897
+ (from.w + (to.w - from.w) * t) * dpr,
3898
+ (from.h + (to.h - from.h) * t) * dpr,
3899
+ ];
3900
+ const oldBox = lerpBox(transition.oldRect,
3901
+ transition.oldTo || transition.newRect);
3902
+ const newBox = lerpBox(transition.newFrom || transition.oldRect,
3903
+ transition.newRect);
3904
+ gl.uniform4f(Uq("u_boxOld"), oldBox[0], oldBox[1], oldBox[2], oldBox[3]);
3905
+ gl.uniform4f(Uq("u_boxNew"), newBox[0], newBox[1], newBox[2], newBox[3]);
3906
+ if (newTex) {
3907
+ gl.activeTexture(gl.TEXTURE0 + unit);
3908
+ gl.bindTexture(gl.TEXTURE_2D, newTex);
3909
+ gl.uniform1i(Uq("u_newimg"), unit);
3910
+ unit++;
3911
+ }
3912
+ }
3913
+ for (const so of solverOps) {
3914
+ const smps = so.def.solver.samplers;
3915
+ for (const key in smps) {
3916
+ gl.activeTexture(gl.TEXTURE0 + unit);
3917
+ gl.bindTexture(gl.TEXTURE_2D, so.targets[smps[key]].a.tex);
3918
+ gl.uniform1i(Uq(`u${so.i}_${key}`), unit);
3919
+ unit++;
3920
+ }
3921
+ }
3922
+ gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
3923
+ };
3924
+
3925
+ // ── Phase I1: coordinate readback ────────────────────────────────
3926
+ //
3927
+ // The overlay canvas is pointer-events:none and the DOM underneath
3928
+ // stays where layout put it, so once a warp displaces content, a link
3929
+ // is clickable where it is NOT drawn. Fixing that needs one fact:
3930
+ // given a screen point, which source pixel is shown there?
3931
+ //
3932
+ // The shader already computes it — output pixel -> sampleP -> fetch.
3933
+ // So rather than reimplement each op's arithmetic on the CPU (which
3934
+ // could silently disagree with what is drawn, and is impossible for
3935
+ // `stir`, a fluid sim with no closed form), run the SAME chain and
3936
+ // read the answer back.
3937
+ //
3938
+ // Cost is one draw of a single fragment plus a 1-pixel readPixels.
3939
+ // Built on first use, so a page that never hit-tests pays nothing.
3940
+ let probe = null;
3941
+ const buildProbe = () => {
3942
+ if (probe) return probe;
3943
+ const p = gl.createProgram();
3944
+ gl.attachShader(p, compile(gl.VERTEX_SHADER, VS));
3945
+ gl.attachShader(p, compile(gl.FRAGMENT_SHADER,
3946
+ buildFragmentShader(rasterTree, rasterNodes, true, transition)));
3947
+ gl.bindAttribLocation(p, 0, "a");
3948
+ gl.linkProgram(p);
3949
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
3950
+ console.warn("[nodality] probe program failed:", gl.getProgramInfoLog(p));
3951
+ gl.deleteProgram(p);
3952
+ probe = { broken: true };
3953
+ return probe;
3954
+ }
3955
+ // One pixel is the entire render target: the viewport is offset so
3956
+ // that the queried pixel is the only one rasterised.
3957
+ const t = gl.createTexture();
3958
+ gl.bindTexture(gl.TEXTURE_2D, t);
3959
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
3960
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
3961
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
3962
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3963
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3964
+ const fbo = gl.createFramebuffer();
3965
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
3966
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
3967
+ const st = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
3968
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
3969
+ if (st !== gl.FRAMEBUFFER_COMPLETE) {
3970
+ console.warn("[nodality] probe framebuffer incomplete:", st);
3971
+ gl.deleteProgram(p); gl.deleteFramebuffer(fbo); gl.deleteTexture(t);
3972
+ probe = { broken: true };
3973
+ return probe;
3974
+ }
3975
+ const cache = {};
3976
+ const Up = (n) => (n in cache ? cache[n] : (cache[n] = gl.getUniformLocation(p, n)));
3977
+ // The chain's static op uniforms, on this program too.
3978
+ gl.useProgram(p);
3979
+ applyStaticUniforms(Up);
3980
+ gl.useProgram(prog);
3981
+ probe = { prog: p, fbo, tex: t, px: new Uint8Array(4), U: Up };
3982
+ return probe;
3983
+ };
3984
+
3985
+ /**
3986
+ * Which source pixel is drawn at this point on screen?
3987
+ *
3988
+ * @param {number} clientX viewport coordinate, as on a PointerEvent
3989
+ * @param {number} clientY
3990
+ * @returns {{x: number, y: number} | null} the point in CLIENT
3991
+ * coordinates that the content visible at (clientX, clientY) came
3992
+ * from — feed it straight to document.elementFromPoint(). Null if
3993
+ * nothing has been drawn yet, the point is outside the canvas, or
3994
+ * the probe could not be built.
3995
+ *
3996
+ * Caveat, documented rather than hidden: `copy` draws several stamps,
3997
+ * so one screen point genuinely maps to several sources. This returns
3998
+ * the primary sample. Phase I4 classifies that case.
3999
+ */
4000
+ // ── Phase I2: the CPU path ───────────────────────────────────────
4001
+ //
4002
+ // A GPU readback is a pipeline sync, which is fine per click and too
4003
+ // expensive per pointermove. An op may declare `map` — the same
4004
+ // coordinate arithmetic its GLSL does, in JS — and a chain whose
4005
+ // coordinate-moving ops all declare one is answered without touching
4006
+ // the GPU.
4007
+ //
4008
+ // The duplication is safe only because I1 exists to check it: the
4009
+ // property test asserts twin and readback agree across the parameter
4010
+ // space. A twin that drifts from its shader is caught, not trusted.
4011
+ //
4012
+ // Bails to null — meaning "ask the GPU" — for anything it cannot do
4013
+ // faithfully:
4014
+ // - an op in a coordinate stage with no `map` and no explicit
4015
+ // `movesCoords: false` (flow, stir)
4016
+ // - a `map` that declines (hexalize with lift)
4017
+ // - any MASKED op, because its effect is lerped by a field this
4018
+ // path does not evaluate
4019
+ const COORD_STAGES = ["warp", "cell", "displace"];
4020
+ const cpuSourceAt = (px, py) => {
4021
+ if (!lastSnap) return null;
4022
+ const byIndex = {};
4023
+ for (const d of lastSnap.drivers) byIndex[d.i] = d;
4024
+ let pt = [px, py];
4025
+ for (const stage of COORD_STAGES) {
4026
+ for (let i = 0; i < rasterNodes.length; i++) {
4027
+ const node = rasterNodes[i];
4028
+ const def = REGISTRY[node.op];
4029
+ if (!def) continue;
4030
+ const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
4031
+ if (!stages.includes(stage)) continue;
4032
+ if (maskedField(node)) return null;
4033
+ if (typeof def.map !== "function") {
4034
+ if (def.movesCoords === false) continue;
4035
+ return null;
4036
+ }
4037
+ const d = byIndex[i];
4038
+ const out = def.map(pt, node, {
4039
+ res: lastSnap.res,
4040
+ dpr,
4041
+ dpos: d ? [d.x, d.y] : [lastSnap.res[0] / 2, lastSnap.res[1] / 2],
4042
+ damt: d ? d.amt : 1,
4043
+ time: lastSnap.time,
4044
+ mouse: lastSnap.mouse,
4045
+ });
4046
+ if (!out) return null;
4047
+ pt = out;
4048
+ }
4049
+ }
4050
+ return pt;
4051
+ };
4052
+
4053
+ /**
4054
+ * @param {object} [opts]
4055
+ * @param {boolean} [opts.gpu] force the readback path, skipping any
4056
+ * declared CPU twin. This is the oracle the twins are checked
4057
+ * against — a `map` that drifted from its shader would
4058
+ * otherwise be undetectable, since both paths would be asked
4059
+ * the same question and only one of them consulted.
4060
+ */
4061
+ const sourceAt = (clientX, clientY, opts) => {
4062
+ if (destroyed || !lastSnap || !textureReady) return null;
4063
+
4064
+ const rect = canvas.getBoundingClientRect();
4065
+ if (rect.width && rect.height && !(opts && opts.gpu)) {
4066
+ // Try the CPU path first — same answer, no pipeline sync.
4067
+ const sxc = canvas.width / rect.width, syc = canvas.height / rect.height;
4068
+ const dx = (clientX - rect.left) * sxc, dy = (clientY - rect.top) * syc;
4069
+ if (dx >= 0 && dy >= 0 && dx < canvas.width && dy < canvas.height) {
4070
+ // The SAME pixel-centre convention the readback uses: floor
4071
+ // to a pixel, then take its centre. Without this the two
4072
+ // paths ask about points up to half a pixel apart, which is
4073
+ // invisible for most ops and enormous for `offset` near its
4074
+ // focus, where dir = (warped - dpos)/d is a singularity.
4075
+ const fx = Math.floor(dx);
4076
+ const fy = Math.floor(canvas.height - dy);
4077
+ const cpu = cpuSourceAt(fx + 0.5, canvas.height - fy - 0.5);
4078
+ if (cpu) {
4079
+ return {
4080
+ x: rect.left + Math.min(Math.max(cpu[0], 0), canvas.width) / sxc,
4081
+ y: rect.top + Math.min(Math.max(cpu[1], 0), canvas.height) / syc,
4082
+ };
4083
+ }
4084
+ }
4085
+ }
4086
+
4087
+ const pr = buildProbe();
4088
+ if (pr.broken) return null;
4089
+
4090
+ const r = canvas.getBoundingClientRect();
4091
+ if (!r.width || !r.height) return null;
4092
+ // Client -> canvas device pixels. The canvas may be scaled by CSS,
4093
+ // so go through the rect rather than assuming dpr.
4094
+ const sx = canvas.width / r.width, sy = canvas.height / r.height;
4095
+ const px = (clientX - r.left) * sx;
4096
+ const py = (clientY - r.top) * sy;
4097
+ if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
4098
+
4099
+ // gl_FragCoord is bottom-up; `py` is top-down.
4100
+ const fx = Math.floor(px);
4101
+ const fy = Math.floor(canvas.height - py);
4102
+
4103
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pr.fbo);
4104
+ gl.useProgram(pr.prog);
4105
+ // The whole target is one pixel; the shader reads the coordinate
4106
+ // it is standing in for from u_probe rather than gl_FragCoord.
4107
+ gl.viewport(0, 0, 1, 1);
4108
+ gl.uniform2f(pr.U("u_probe"), fx, fy);
4109
+ applyUniforms(pr.U, lastSnap);
4110
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
4111
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pr.px);
4112
+
4113
+ // Restore what draw() expects to find.
4114
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
4115
+ gl.useProgram(prog);
4116
+ gl.viewport(0, 0, canvas.width, canvas.height);
4117
+
4118
+ // Two bytes per axis, as packed in the probe tail.
4119
+ const b = pr.px;
4120
+ const qx = (b[0] + b[1] / 255) / 255;
4121
+ const qy = (b[2] + b[3] / 255) / 255;
4122
+ // Device px (top-down) -> client coordinates.
4123
+ return {
4124
+ x: r.left + (qx * canvas.width) / sx,
4125
+ y: r.top + (qy * canvas.height) / sy,
4126
+ };
4127
+ };
4128
+
4129
+ // ── Phase I3: event retargeting ──────────────────────────────────
4130
+ //
4131
+ // With I1 able to say where a pixel came from, the fix is mechanical:
4132
+ // catch pointer events on the host in the CAPTURE phase (before they
4133
+ // reach the element the browser picked), ask where the content under
4134
+ // the cursor actually lives, and re-dispatch there.
4135
+ //
4136
+ // Honest boundary, stated because it cannot be fixed from here: CSS
4137
+ // `:hover` and `:active` are computed by the browser from the real
4138
+ // pointer position over the real box. A synthetic MouseEvent does not
4139
+ // move them. So JS handlers retarget correctly and CSS pseudo-classes
4140
+ // still follow the undisplaced layout. Retargeting those would mean
4141
+ // moving the DOM, which is the one thing this library promises not to
4142
+ // do. Focus and keyboard were never wrong — they never went through
4143
+ // coordinates.
4144
+ const RETARGET = "__nodalityRetargeted";
4145
+ // Opt out per pipeline, like `live: false`. An effect used as pure
4146
+ // decoration should not pay for a GPU readback per click.
4147
+ const interactive = !rasterNodes.some((n) => n.interactive === false);
4148
+
4149
+ // Phase I3c. CSS :hover is computed by the browser from the real
4150
+ // pointer over the real box, and a synthetic MouseEvent does not move
4151
+ // it — so on a displaced element the wrong thing lights up. It CAN be
4152
+ // mirrored: track the hovered ancestor chain at the SOURCE position
4153
+ // and mark it with an attribute the page can style.
4154
+ //
4155
+ // { op: "flow", hoverAttr: true } → [data-nodality-hover]
4156
+ //
4157
+ // OPT-IN, and this is the reason: writing that attribute is a DOM
4158
+ // mutation, and this library's headline property is that an effect
4159
+ // never touches the host subtree — asserted with a MutationObserver
4160
+ // in the e2e suite. Turning it on trades that property for hover
4161
+ // fidelity, so it must be the page's decision, not a default. Page
4162
+ // authors also have to write `[data-nodality-hover]` rather than
4163
+ // `:hover`, which is a real cost.
4164
+ const HOVER_ATTR = "data-nodality-hover";
4165
+ const hoverMirror = rasterNodes.some((n) => n.hoverAttr === true);
4166
+ let hoverChain = [];
4167
+ const setHoverChain = (target) => {
4168
+ const next = [];
4169
+ for (let n = target; n && n !== el.parentNode; n = n.parentElement) next.push(n);
4170
+ const keep = new Set(next);
4171
+ for (const n of hoverChain) if (!keep.has(n)) n.removeAttribute(HOVER_ATTR);
4172
+ for (const n of next) if (!n.hasAttribute(HOVER_ATTR)) n.setAttribute(HOVER_ATTR, "");
4173
+ hoverChain = next;
4174
+ };
4175
+
4176
+ /** Build an event of the same kind, at corrected coordinates. */
4177
+ const cloneAt = (e, x, y) => {
4178
+ const Ctor = (typeof PointerEvent !== "undefined" && e instanceof PointerEvent)
4179
+ ? PointerEvent
4180
+ : MouseEvent;
4181
+ const ev = new Ctor(e.type, {
4182
+ bubbles: true, cancelable: e.cancelable, composed: true,
4183
+ view: e.view || (typeof window !== "undefined" ? window : null),
4184
+ detail: e.detail,
4185
+ clientX: x, clientY: y,
4186
+ // Keep screen coords consistent with the shift we applied.
4187
+ screenX: e.screenX + (x - e.clientX),
4188
+ screenY: e.screenY + (y - e.clientY),
4189
+ ctrlKey: e.ctrlKey, altKey: e.altKey,
4190
+ shiftKey: e.shiftKey, metaKey: e.metaKey,
4191
+ button: e.button, buttons: e.buttons,
4192
+ relatedTarget: e.relatedTarget,
4193
+ // Ignored by MouseEvent, meaningful for PointerEvent.
4194
+ pointerId: e.pointerId, pointerType: e.pointerType,
4195
+ isPrimary: e.isPrimary, pressure: e.pressure,
4196
+ });
4197
+ ev[RETARGET] = true;
4198
+ return ev;
4199
+ };
4200
+
4201
+ // A GPU readback per pointermove would be one sync per frame. Moves
4202
+ // are throttled to one probe per animation frame; clicks are rare and
4203
+ // always probed. Phase I2 removes the sync for ops that can declare
4204
+ // their coordinate math on the CPU.
4205
+ let lastMoveProbe = 0;
4206
+
4207
+ const onRetarget = (e) => {
4208
+ if (!interactive || destroyed || e[RETARGET]) return;
4209
+ if (e.type === "pointermove" || e.type === "mousemove") {
4210
+ const t = (typeof performance !== "undefined") ? performance.now() : 0;
4211
+ if (t - lastMoveProbe < 12) return;
4212
+ lastMoveProbe = t;
4213
+ }
4214
+ const s = sourceAt(e.clientX, e.clientY);
4215
+ if (!s) return;
4216
+ // Nothing moved here: let the browser's own hit-testing stand.
4217
+ if (Math.abs(s.x - e.clientX) < 0.5 && Math.abs(s.y - e.clientY) < 0.5) return;
4218
+
4219
+ // Returns null when the source lands outside the VIEWPORT — an
4220
+ // element scrolled half off-screen whose displacement pushes the
4221
+ // source past the edge. Declining is the right answer there: the
4222
+ // native hit stands, rather than dispatching somewhere wrong.
4223
+ const target = typeof document.elementFromPoint === "function"
4224
+ ? document.elementFromPoint(s.x, s.y)
4225
+ : null;
4226
+ // Only ever redirect INTO our own subtree. A displacement that
4227
+ // resolves outside the host is a bug or an edge case, and
4228
+ // dispatching into unrelated page content would be worse than
4229
+ // doing nothing.
4230
+ if (!target || target === e.target || !el.contains(target)) return;
4231
+
4232
+ // Hover, if the page opted in. Done for moves as well as clicks,
4233
+ // since that is when hover state changes.
4234
+ if (hoverMirror) setHoverChain(target);
4235
+
4236
+ // Phase I3b. Focus follows the pointer to the element that was
4237
+ // actually clicked. Without this, clicking a displaced link moves
4238
+ // focus to whatever sits under the cursor in the undisplaced
4239
+ // layout — so the picture, the click and the focus ring disagree.
4240
+ // Only on activating events: a pointermove must not steal focus.
4241
+ if (e.type === "pointerdown" || e.type === "mousedown" || e.type === "click") {
4242
+ const focusable = target.closest &&
4243
+ target.closest("a, button, input, select, textarea, [tabindex]");
4244
+ if (focusable && typeof focusable.focus === "function") {
4245
+ focusable.focus({ preventScroll: true });
4246
+ }
4247
+ }
4248
+
4249
+ // Stop the original in the capture phase so the element the
4250
+ // browser picked never sees it, then deliver the corrected one.
4251
+ e.stopPropagation();
4252
+ if (e.cancelable) e.preventDefault();
4253
+ target.dispatchEvent(cloneAt(e, s.x, s.y));
4254
+ };
4255
+
4256
+ const RETARGET_EVENTS = ["pointerdown", "pointerup", "pointermove", "click",
4257
+ "dblclick", "contextmenu", "mousedown", "mouseup"];
4258
+ // Leaving the element must clear the mirrored hover, or it sticks.
4259
+ const onRetargetLeave = () => { if (hoverMirror) setHoverChain(null); };
4260
+ if (interactive) {
4261
+ for (const t of RETARGET_EVENTS) el.addEventListener(t, onRetarget, true);
4262
+ el.addEventListener("pointerleave", onRetargetLeave, true);
4263
+ }
4264
+
2522
4265
  let visible = true;
2523
4266
  const draw = () => {
2524
4267
  if (destroyed) return;
4268
+ // Self-healing: whatever else touched the ink or the canvas since
4269
+ // the last frame, progress is the authority.
4270
+ syncTransitionView();
2525
4271
  if (visible && textureReady) {
2526
4272
  const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
2527
4273
  const dt = Math.max(0, (now - lastFrame) / 1000);
@@ -2540,10 +4286,20 @@ function applyRasterPipeline(el, rasterNodes) {
2540
4286
  gl.viewport(0, 0, canvas.width, canvas.height);
2541
4287
  gl.clearColor(0, 0, 0, 0);
2542
4288
  gl.clear(gl.COLOR_BUFFER_BIT);
2543
- gl.uniform2f(U("u_res"), canvas.width, canvas.height);
2544
- gl.uniform2f(U("u_mouse"), mouse[0], mouse[1]);
2545
- gl.uniform1f(U("u_time"), now / 1000);
2546
- // Evaluate each reactive op's driver and upload its focus.
4289
+ // Everything the frame needs, computed ONCE, then written to
4290
+ // a program. Split this way for phase I1: the probe pass has
4291
+ // to reproduce this frame on a different program, and both
4292
+ // the hover easing above and `tick()` below MUTATE — running
4293
+ // them again for a hit-test would advance every animation by
4294
+ // an extra step per pointer event.
4295
+ const snap = {
4296
+ res: [canvas.width, canvas.height],
4297
+ mouse: [mouse[0], mouse[1]],
4298
+ time: now / 1000,
4299
+ t: progress,
4300
+ drivers: [],
4301
+ dyn: [],
4302
+ };
2547
4303
  if (driverNodes.length > 0) {
2548
4304
  hover += (hoverTarget - hover) * (1 - Math.exp(-dt * 8));
2549
4305
  const dctx = {
@@ -2553,34 +4309,17 @@ function applyRasterPipeline(el, rasterNodes) {
2553
4309
  };
2554
4310
  for (const d of driverNodes) {
2555
4311
  const v = d.fn(dctx);
2556
- gl.uniform2f(U(`u${d.i}_dpos`), v.x, v.y);
2557
- gl.uniform1f(U(`u${d.i}_damt`), v.amt);
4312
+ snap.drivers.push({ i: d.i, x: v.x, y: v.y, amt: v.amt });
2558
4313
  }
2559
4314
  }
2560
- // Advance and upload per-frame CPU simulations.
2561
4315
  if (dynamicOps.length > 0) {
2562
4316
  const ctx = { w: canvas.width, h: canvas.height, mouseX: mouse[0], mouseY: mouse[1], dt: dt, t: now / 1000 };
2563
4317
  for (const d of dynamicOps) {
2564
- const ups = d.def.tick(d.state, ctx);
2565
- for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
2566
- }
2567
- }
2568
- // Content on unit 0, then each solver's named result
2569
- // textures (velocity, dye, ...) on units above it.
2570
- gl.activeTexture(gl.TEXTURE0);
2571
- gl.bindTexture(gl.TEXTURE_2D, tex);
2572
- gl.uniform1i(U("u_tex"), 0);
2573
- let unit = 1;
2574
- for (const s of solverOps) {
2575
- const smps = s.def.solver.samplers;
2576
- for (const key in smps) {
2577
- gl.activeTexture(gl.TEXTURE0 + unit);
2578
- gl.bindTexture(gl.TEXTURE_2D, s.targets[smps[key]].a.tex);
2579
- gl.uniform1i(U(`u${s.i}_${key}`), unit);
2580
- unit++;
4318
+ snap.dyn.push({ i: d.i, ups: d.def.tick(d.state, ctx) });
2581
4319
  }
2582
4320
  }
2583
- gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
4321
+ lastSnap = snap;
4322
+ applyUniforms(U, snap);
2584
4323
  lastFrame = now;
2585
4324
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2586
4325
  }
@@ -2650,6 +4389,22 @@ function applyRasterPipeline(el, rasterNodes) {
2650
4389
 
2651
4390
  if (typeof ResizeObserver !== "undefined") {
2652
4391
  ro = new ResizeObserver(() => {
4392
+ // SNAPSHOT ONLY. In live mode the host's children have moved
4393
+ // inside the canvas, so the canvas is the host's only in-flow
4394
+ // child and the host's height is padding + canvas + padding.
4395
+ // Measuring the host to size the canvas is therefore circular:
4396
+ // every observation adds the padding back, resizes the canvas,
4397
+ // and re-triggers this observer. A 98px two-column panel walked
4398
+ // to ~4000px on load, growing by its own padding each pass.
4399
+ //
4400
+ // The live path is handled by onWinResize, which measures the
4401
+ // SUBTREE inside the canvas rather than the host — the one
4402
+ // measurement that is not downstream of the value being set.
4403
+ //
4404
+ // Guarded on the current mode rather than by not observing at
4405
+ // all, so a mid-session fallbackToSnapshot re-enables it with
4406
+ // no re-observation.
4407
+ if (mode === "live") return;
2653
4408
  syncCanvasBox();
2654
4409
  clearTimeout(resizeTimer);
2655
4410
  resizeTimer = setTimeout(remeasureAndCapture, 150);
@@ -2709,10 +4464,122 @@ function applyRasterPipeline(el, rasterNodes) {
2709
4464
  }
2710
4465
  }
2711
4466
 
2712
- return {
4467
+ // A param change is DATA changing, and more than one thing may be
4468
+ // showing that data: the inspector panel, a code preview, a future node
4469
+ // editor. Whoever writes a value cannot know who else is displaying it,
4470
+ // so setParam announces rather than expecting callers to coordinate.
4471
+ //
4472
+ // A DOM CustomEvent rather than a subscriber list: consumers come and
4473
+ // go with the page, listeners unregister themselves, and nothing has to
4474
+ // hold a reference to a pipeline that may be rebuilt out from under it.
4475
+ const announce = (index, key, value, prev, how) => {
4476
+ if (typeof CustomEvent !== "function" || typeof document === "undefined") return;
4477
+ document.dispatchEvent(new CustomEvent(RASTER_PARAM_EVENT, {
4478
+ detail: { pipeline: handle, el, index, key, value, prev, how },
4479
+ }));
4480
+ };
4481
+
4482
+ // ── Live introspection (phase H3) ─────────────────────────────────
4483
+ // A param is LIVE if the op reads it in uniforms() — changing it is a
4484
+ // uniform upload. It is STRUCTURAL if the op reads it in code(),
4485
+ // because that value is compiled into the GLSL and the only way to
4486
+ // change it is to build a new shader. See `structural` on each
4487
+ // registry entry; anything unrecognised rebuilds, which is the safe
4488
+ // direction to be wrong in.
4489
+ const handle = {
2713
4490
  canvas,
4491
+ // The FLATTENED node list — the one that owns the uniform slots,
4492
+ // so index i here is the `u<i>_` prefix in the shader and what an
4493
+ // inspector must show. Nodes nested in a `merge` appear inline.
4494
+ nodes: rasterNodes,
4495
+ get backend() { return mode; },
4496
+
4497
+ // Phase I1. Where the content visible at a screen point came
4498
+ // from, in client coordinates — the input to hit-testing through
4499
+ // a displacement. Defined below, next to the probe program.
4500
+ sourceAt: (clientX, clientY, opts) => sourceAt(clientX, clientY, opts),
4501
+
4502
+ // Phase T1. Transition progress. `t` is an INPUT, not a timer —
4503
+ // a timeline is one driver of it, scroll scrub and a test are
4504
+ // others. Everything downstream is a pure function of it, which
4505
+ // is what makes transitions deterministic to test and
4506
+ // interruptible for free.
4507
+ get progress() { return progress; },
4508
+ setProgress(t) {
4509
+ const next = Math.min(1, Math.max(0, Number(t) || 0));
4510
+ if (next === progress) return progress;
4511
+ progress = next;
4512
+ uploadKeyframes();
4513
+ // Phase T2, gap 1. The real NEW element is still in the tree
4514
+ // under the canvas, so without this it is visible at t=0 —
4515
+ // the morph would show the destination behind its own start
4516
+ // frame. Suppress its ink for the duration and release at
4517
+ // completion, which is also the moment the DOM becomes the
4518
+ // truth again: selection, hover and video resume on the real
4519
+ // element rather than on a picture of it.
4520
+ //
4521
+ // Driven off `progress` rather than a lifecycle callback, so
4522
+ // it stays a pure function of t (P-1) and a scrub backwards
4523
+ // out of 1 re-hides correctly.
4524
+ syncTransitionView();
4525
+ return progress;
4526
+ },
4527
+
4528
+ /**
4529
+ * Change one param of one node, live where possible.
4530
+ * Returns "uniform" | "rebuild" | false (unknown node).
4531
+ */
4532
+ setParam(i, key, value) {
4533
+ const node = rasterNodes[i];
4534
+ if (!node || destroyed) return false;
4535
+ const prev = node[key];
4536
+ node[key] = value;
4537
+
4538
+ if (isStructuralChange(node.op, key, value, prev)) {
4539
+ handle.rebuild();
4540
+ announce(i, key, value, prev, "rebuild");
4541
+ return "rebuild";
4542
+ }
4543
+ const def = REGISTRY[node.op];
4544
+ if (def && def.uniforms) {
4545
+ const us = def.uniforms(node, dpr);
4546
+ gl.useProgram(prog);
4547
+ for (const k in us) setUniform(U(`u${i}_${k}`), us[k][0], us[k][1]);
4548
+ // The phase-I1 probe is a SECOND program running the same
4549
+ // chain, so it needs the new value too. Without this it
4550
+ // keeps answering with the parameters it was built with —
4551
+ // and since the inspector drives setParam on every drag,
4552
+ // hit-testing would silently drift away from the picture
4553
+ // the moment anyone tuned anything.
4554
+ if (probe && !probe.broken) {
4555
+ gl.useProgram(probe.prog);
4556
+ for (const k in us) setUniform(probe.U(`u${i}_${k}`), us[k][0], us[k][1]);
4557
+ gl.useProgram(prog);
4558
+ }
4559
+ }
4560
+ announce(i, key, value, prev, "uniform");
4561
+ return "uniform";
4562
+ },
4563
+
4564
+ /**
4565
+ * Tear down and re-apply from the (mutated) node tree. Needed for
4566
+ * anything baked into the shader. `sourceNodes` is the ORIGINAL
4567
+ * tree, merge sub-chains and all — flattening loses that nesting,
4568
+ * and rebuilding from the flat list would silently promote a
4569
+ * merge branch to a top-level op.
4570
+ */
4571
+ rebuild() {
4572
+ if (destroyed) return null;
4573
+ const host = el;
4574
+ handle.destroy();
4575
+ return applyRasterPipeline(host, sourceNodes);
4576
+ },
4577
+ };
4578
+ ACTIVE.add(handle);
4579
+ return Object.assign(handle, {
2714
4580
  refresh: () => (mode === "live" ? onPaint() : snapshotCapture()),
2715
4581
  destroy() {
4582
+ ACTIVE.delete(handle);
2716
4583
  destroyed = true;
2717
4584
  cancelAnimationFrame(raf);
2718
4585
  clearTimeout(resizeTimer);
@@ -2722,6 +4589,11 @@ function applyRasterPipeline(el, rasterNodes) {
2722
4589
  if (typeof window !== "undefined") window.removeEventListener("resize", onWinResize);
2723
4590
  el.removeEventListener("mousemove", onMove);
2724
4591
  el.removeEventListener("touchmove", onMove);
4592
+ // Phase I3 listeners are registered in the capture phase, so
4593
+ // they must be removed with the same flag or they leak.
4594
+ for (const t of RETARGET_EVENTS) el.removeEventListener(t, onRetarget, true);
4595
+ el.removeEventListener("pointerleave", onRetargetLeave, true);
4596
+ setHoverChain(null); // never leave the attribute behind
2725
4597
  el.removeEventListener("mouseenter", onEnter);
2726
4598
  el.removeEventListener("mouseleave", onLeave);
2727
4599
  canvas.removeEventListener("paint", onPaint);
@@ -2730,17 +4602,44 @@ function applyRasterPipeline(el, rasterNodes) {
2730
4602
  for (const n in s.progs) gl.deleteProgram(s.progs[n].prog);
2731
4603
  }
2732
4604
  solverOps.length = 0;
4605
+ // The probe pass owns a program, a 1x1 texture and an FBO —
4606
+ // only allocated if something hit-tested.
4607
+ if (probe && !probe.broken) {
4608
+ gl.deleteProgram(probe.prog);
4609
+ gl.deleteFramebuffer(probe.fbo);
4610
+ gl.deleteTexture(probe.tex);
4611
+ }
4612
+ probe = null;
4613
+ if (oldTex) { gl.deleteTexture(oldTex); oldTex = null; }
4614
+ if (newTex) { gl.deleteTexture(newTex); newTex = null; }
2733
4615
  while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
2734
4616
  showHostInk();
2735
4617
  el.style.visibility = "";
2736
4618
  canvas.remove();
2737
4619
  },
2738
- };
4620
+ });
2739
4621
  }
2740
4622
 
2741
4623
  export {
2742
4624
  applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
2743
4625
  isHTMLInCanvasAvailable, DRIVER_NAMES,
4626
+ // Phase H3. The registry is already effectively public — registerRasterOp
4627
+ // mutates it — and a dev tool needs to read an op's stage and params to
4628
+ // show them. `isStructuralChange` is exported so the inspector can label
4629
+ // a control "rebuilds" before the user drags it, rather than after.
4630
+ activeRasterPipelines, isStructuralChange, REGISTRY, RASTER_PARAM_EVENT,
4631
+ // Phase H4. The op contract, as data: the vocabularies a third-party
4632
+ // op is checked against, and the shared params every op inherits from
4633
+ // the pipeline rather than declaring itself.
4634
+ RASTER_STAGES, RASTER_UNITS, FRAMEWORK_DOC, validateRasterOp,
4635
+ // Phase T1. Keyframed params, resolved against the units an op
4636
+ // already declares.
4637
+ isKeyframed, sampleKeyframes, resolveNode,
4638
+ // Phase T3. Choreography: per-node windows and easing over progress.
4639
+ EASINGS, EASING_NAMES, localProgress,
4640
+ // Phase I4. How each op behaves under hit-testing, derived from its
4641
+ // own declarations.
4642
+ interactionClass,
2744
4643
  // Exported so which branch a `switch` takes can be asserted directly
2745
4644
  // rather than inferred from pixels.
2746
4645
  resolveSwitches,