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
@@ -0,0 +1,329 @@
1
+ /*!
2
+ * nodality v1.1.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /*!
8
+ * raster-presets.js — named, COMPOSABLE raster op chains.
9
+ *
10
+ * A preset is data: an array of raster nodes, exactly what you would have
11
+ * written by hand. `preset("liquid-hero")` is a shortcut, not a wrapper —
12
+ * the value it returns is plain nodes, and once you have it the preset has
13
+ * no further existence. Nothing at runtime depends on this module.
14
+ *
15
+ * new Text("Hold").set({ raster: preset("liquid-hero") })
16
+ *
17
+ * COMBINING is the point (the Houdini part). Chains already compose in the
18
+ * pipeline — stages run field → warp → cell → displace → color whatever
19
+ * order the nodes are written in — but two chains concatenated naively can
20
+ * still collide, in one specific way: FIELDS ARE GLOBAL. If two presets
21
+ * both write `as: "mask"`, the second silently overwrites the first and
22
+ * both look subtly wrong. `compose()` renames each preset's private fields
23
+ * so that cannot happen:
24
+ *
25
+ * compose("liquid-hero", "print-shop") // fluid, then screened
26
+ * compose("spotlight", ["ripple", { strength: 40 }])
27
+ *
28
+ * A field a preset CONSUMES but does not PRODUCE is left untouched, so
29
+ * deliberate cross-wiring still works: produce a field in one preset, read
30
+ * it in the next.
31
+ *
32
+ * Pure — no DOM, no imports beyond the op vocabulary it validates against.
33
+ */
34
+
35
+ import { RASTER_OP_NAMES, DRIVER_NAMES } from "./raster-ops.js";
36
+ import { didYouMean } from "./suggest.js";
37
+
38
+ // `live` records what the HTML-in-Canvas backend buys a preset:
39
+ // undefined — snapshot is the whole effect; live changes nothing visible
40
+ // "enhanced" — works on snapshot, better live (a static field vs a live one)
41
+ // "required" — needs live capture; on snapshot it correctly does nothing
42
+ //
43
+ // The gallery badges from this. It is documentation, not a gate: the
44
+ // pipeline decides the backend itself and degrades silently either way.
45
+ const PRESETS = {
46
+ // ── transitions (phase T3) ───────────────────────────────────────
47
+ // Chains meant for transition mode: every param keyframed so the
48
+ // effect is absent at both ends and peaks in the middle. They are
49
+ // ordinary nodes — no transition-specific authoring language — which
50
+ // is why compose() and every third-party op still work here.
51
+ "t-vhs": {
52
+ summary: "Tape-glitch morph: chroma splits and the image tears, " +
53
+ "strongest at the midpoint, gone at both ends.",
54
+ nodes: [
55
+ { op: "aberration", amount: [0, 18, 0] },
56
+ // Windowed so the tear starts after the split has opened —
57
+ // staggering inside one preset rather than needing a timeline.
58
+ { op: "flow", strength: [0, 34, 0], scale: 60, speed: 0,
59
+ window: [0.15, 0.95], ease: "in-out" },
60
+ ],
61
+ },
62
+ "t-dissolve": {
63
+ summary: "Ordered-dither dissolve — the old state breaks into " +
64
+ "pixels before the new one resolves.",
65
+ nodes: [
66
+ // Identity at BOTH ends. A transition preset that still shows
67
+ // its effect at t=0 or t=1 pops when the canvas hands the
68
+ // frame back to the real element.
69
+ { op: "dither", levels: [8, 2, 8], size: [1, 7, 1], amount: [0, 1, 0],
70
+ ease: "in-out" },
71
+ ],
72
+ },
73
+ // The first preset that treats the two halves of a morph as separate
74
+ // things: the outgoing state burns to ink while the incoming one
75
+ // resolves out of a screen. A crossfade decorated after the blend
76
+ // cannot express this — both ends would get the same treatment.
77
+ "t-split": {
78
+ summary: "Per-side morph: the old state posterises to duotone while " +
79
+ "the new one resolves through a halftone screen.",
80
+ nodes: [
81
+ { op: "duotone", side: "old", amount: [0, 1, 1],
82
+ dark: "#0B1B2B", light: "#1abc9c" },
83
+ { op: "halftone", side: "new", size: [10, 6, 2], amount: [1, 1, 0],
84
+ softness: 0.35, angle: 15 },
85
+ ],
86
+ },
87
+
88
+ "t-bloom": {
89
+ summary: "A soft halftone bloom through the middle of the morph.",
90
+ nodes: [
91
+ { op: "halftone", size: [2, 9, 2], amount: [0, 1, 0], softness: 0.4, angle: 15,
92
+ window: [0.1, 0.9] },
93
+ ],
94
+ },
95
+
96
+ // ── fluid ────────────────────────────────────────────────────────
97
+ // The two `stir` numbers are the PROVEN values from
98
+ // designerTest/raster-spot.html, not fresh guesses: radius 0.055 is
99
+ // the splat width at which consecutive pointer samples overlap into a
100
+ // stroke rather than a wash, and resolution 256 is where the
101
+ // deformation reads as soft instead of faceted.
102
+ "liquid-hero": {
103
+ summary: "A ripple that trails the pointer, then relaxes back in ~1.5s.",
104
+ live: "enhanced",
105
+ nodes: [{ op: "stir", by: "mouse", radius: 0.055, force: 2.2,
106
+ strength: 16, curl: 0.8, resolution: 256, decay: 0.988 }],
107
+ },
108
+ linger: {
109
+ summary: "The same current, held for four or five seconds. Only `decay` differs.",
110
+ live: "enhanced",
111
+ nodes: [{ op: "stir", by: "mouse", radius: 0.055, force: 2.2,
112
+ strength: 7, curl: 0.8, resolution: 256, decay: 0.9985,
113
+ dye: 0, sheen: 0 }],
114
+ },
115
+ // Divergence-free by construction, so it looks fluid with no solver —
116
+ // and unlike `stir` it needs no float render targets.
117
+ "flow-field": {
118
+ summary: "A standing current across the whole element. No solver, runs anywhere.",
119
+ nodes: [{ op: "flow", strength: 18, scale: 120, speed: 0.25 }],
120
+ },
121
+ "flow-pointer": {
122
+ summary: "The same current, but only around the pointer.",
123
+ nodes: [{ op: "flow", by: "mouse", strength: 26, scale: 100, radius: 320 }],
124
+ },
125
+
126
+ // ── print ────────────────────────────────────────────────────────
127
+ "print-shop": {
128
+ summary: "Offset-litho dot screen with white seams under the glyphs.",
129
+ nodes: [{ op: "halftone", size: 6, angle: 15 },
130
+ { op: "edges", color: "#FFFFFF" }],
131
+ },
132
+ newsprint: {
133
+ summary: "One-bit ordered dither — ink on paper, no greys.",
134
+ nodes: [{ op: "dither", mono: true, ink: "#0B1B2B" }],
135
+ },
136
+ crt: {
137
+ summary: "Posterised to a few levels, with a lens-like colour split.",
138
+ nodes: [{ op: "dither", levels: 3 }, { op: "aberration", amount: 6 }],
139
+ },
140
+
141
+ // ── glass and lenses ─────────────────────────────────────────────
142
+ glass: {
143
+ summary: "Refractive metaball lens over untouched content.",
144
+ nodes: [{ op: "blobs", refract: 70, dispersion: 1, frost: 0.25 }],
145
+ },
146
+ "hex-lens": {
147
+ summary: "Hex cells rise toward the pointer; flush and invisible at rest.",
148
+ nodes: [{ op: "hexalize", size: 26, lift: 0.3, by: "hover", radius: 220 }],
149
+ },
150
+ ripple: {
151
+ summary: "The coordinate space pushes away from the pointer.",
152
+ nodes: [{ op: "offset", by: "mouse", strength: 22, radius: 280 }],
153
+ },
154
+
155
+ // ── masked ───────────────────────────────────────────────────────
156
+ // A field producer plus a consumer: the screen exists only inside a
157
+ // disc that rides with the pointer. `compose()` renames "spot" when
158
+ // this is combined with anything else that produces a field.
159
+ spotlight: {
160
+ summary: "A duotone disc that travels with the pointer; the rest is untouched.",
161
+ nodes: [{ op: "mask", from: "radial", as: "spot", by: "mouse", radius: 200 },
162
+ { op: "duotone", masked: "spot", colors: ["#104B87", "#E8FF00"] }],
163
+ },
164
+ turbulent: {
165
+ summary: "Animated noise drives the displacement, so the warp breathes.",
166
+ nodes: [{ op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
167
+ { op: "offset", masked: "turbulence", by: "static", strength: 30 }],
168
+ },
169
+
170
+ // ── temporal ─────────────────────────────────────────────────────
171
+ echoes: {
172
+ summary: "Changing content leaves a fading trail behind it.",
173
+ live: "required",
174
+ nodes: [{ op: "echo", decay: 0.92, strength: 0.85 }],
175
+ },
176
+ // The clearest live-only demonstration in the set, and the reason it is
177
+ // worth having a second one: a long tinted trail over content that is
178
+ // CHANGING. `echo` accumulates the texture into a persistent buffer, so
179
+ // on the snapshot backend — one frozen image, re-uploaded never — the
180
+ // accumulation converges to that same still frame and you see nothing
181
+ // at all. That is not a bug to work around; it is the honest signal
182
+ // that this effect needs per-frame capture of live DOM.
183
+ //
184
+ // Run it with the origin trial off and the content simply sits there.
185
+ // Turn on chrome://flags/#canvas-draw-element and it smears.
186
+ comet: {
187
+ summary: "A long, tinted comet tail. Needs live capture — on snapshot you see nothing move.",
188
+ live: "required",
189
+ nodes: [{ op: "echo", decay: 0.975, strength: 0.95,
190
+ tint: "#7FD4FF", tintAmount: 0.65 }],
191
+ },
192
+ };
193
+
194
+ const clone = (v) => JSON.parse(JSON.stringify(v));
195
+
196
+ /** Preset names, in declaration order. */
197
+ function presetNames() {
198
+ return Object.keys(PRESETS);
199
+ }
200
+
201
+ /** `{ summary, live }` for a preset — what the gallery badges from. */
202
+ function presetInfo(name) {
203
+ const def = PRESETS[name];
204
+ if (!def) throw new Error("[nodality] " + didYouMean(name, presetNames(), "raster preset"));
205
+ return { summary: def.summary, live: def.live };
206
+ }
207
+
208
+ /**
209
+ * A preset's nodes, as a fresh array.
210
+ *
211
+ * `overrides` is applied to EVERY node in the chain, shallowly — which is
212
+ * what you want for the single-node presets and for cross-cutting knobs
213
+ * like `by:`; reach for the raw array when a multi-node chain needs
214
+ * per-node control.
215
+ *
216
+ * Deep-cloned, so a caller mutating the result cannot corrupt the registry
217
+ * for the next caller. That matters more than it sounds: the pipeline
218
+ * writes to node objects (solver state, discovered uniform forms).
219
+ */
220
+ function preset(name, overrides = {}) {
221
+ const def = PRESETS[name];
222
+ if (!def) throw new Error("[nodality] " + didYouMean(name, presetNames(), "raster preset"));
223
+ return clone(def.nodes).map((node) => Object.assign(node, overrides));
224
+ }
225
+
226
+ // Which fields a chain writes (`as:`, defaulting to "mask") and which it
227
+ // reads (`masked:`, where `true` means the default field).
228
+ const producedField = (n) => (n.op === "mask" || n.op === "noise" ? (n.as || "mask") : null);
229
+ const consumedField = (n) =>
230
+ n.masked === true ? "mask" : (typeof n.masked === "string" ? n.masked : null);
231
+
232
+ /**
233
+ * Concatenate presets into one chain, safe against field collisions.
234
+ *
235
+ * Accepts names, `[name, overrides]` pairs, or raw node arrays:
236
+ *
237
+ * compose("liquid-hero", "print-shop")
238
+ * compose(["ripple", { strength: 40 }], "newsprint")
239
+ * compose("spotlight", [{ op: "halftone" }])
240
+ *
241
+ * A field that a preset both produces and consumes is PRIVATE and gets
242
+ * renamed (`spot` → `c0_spot`), so two presets that happen to use the same
243
+ * field name cannot overwrite each other. A field consumed but not
244
+ * produced within the same preset is left alone, so you can still wire one
245
+ * preset's field into the next deliberately.
246
+ *
247
+ * Order is preserved and matters within a stage; across stages the
248
+ * pipeline's own field→warp→cell→displace→color ordering applies, so
249
+ * `compose("print-shop", "ripple")` and the reverse render the same.
250
+ */
251
+ function compose(...parts) {
252
+ const out = [];
253
+ parts.forEach((part, i) => {
254
+ const nodes = Array.isArray(part) && part.length && typeof part[0] === "object"
255
+ ? clone(part) // raw node array
256
+ : Array.isArray(part)
257
+ ? preset(part[0], part[1] || {}) // [name, overrides]
258
+ : preset(part); // name
259
+
260
+ // A field is PRIVATE to this part only if the part both produces
261
+ // AND consumes it — that is a self-contained subgraph, and its
262
+ // name is an implementation detail nobody outside should see.
263
+ //
264
+ // Produced-but-not-consumed is EXPOSED: the part is publishing a
265
+ // field for a later part to read, so renaming it would break the
266
+ // wiring. Consumed-but-not-produced is likewise left alone: the
267
+ // part is reading someone else's field. Renaming either is how the
268
+ // first draft of this broke deliberate cross-wiring.
269
+ const produced = new Set(nodes.map(producedField).filter(Boolean));
270
+ const consumed = new Set(nodes.map(consumedField).filter(Boolean));
271
+ const isPrivate = (f) => produced.has(f) && consumed.has(f);
272
+ const rename = (f) => `c${i}_${f}`;
273
+ for (const node of nodes) {
274
+ const p = producedField(node);
275
+ if (p && isPrivate(p)) node.as = rename(p);
276
+ const c = consumedField(node);
277
+ if (c && isPrivate(c)) node.masked = rename(c);
278
+ }
279
+ out.push(...nodes);
280
+ });
281
+ return out;
282
+ }
283
+
284
+ /**
285
+ * Every op name, driver and field reference in a chain, checked against the
286
+ * live vocabulary. Returns `{ ok, errors }` — never throws, so a gallery
287
+ * can render what is valid and report what is not.
288
+ *
289
+ * The field check is the one worth having: a `masked:` naming a field
290
+ * nothing produces is not an error in the pipeline — the shader declares
291
+ * every referenced field at 1.0, so the op simply renders unmasked. That
292
+ * is a silent no-op, which is the bug class this library is least willing
293
+ * to tolerate.
294
+ */
295
+ function validateChain(nodes) {
296
+ const errors = [];
297
+ if (!Array.isArray(nodes)) return { ok: false, errors: ["chain must be an array"] };
298
+
299
+ const produced = new Set();
300
+ nodes.forEach((node, i) => {
301
+ if (!node || typeof node !== "object" || typeof node.op !== "string") {
302
+ errors.push(`[${i}] every node needs a string \`op\``);
303
+ return;
304
+ }
305
+ if (!RASTER_OP_NAMES.includes(node.op)) {
306
+ errors.push(`[${i}] ` + didYouMean(node.op, RASTER_OP_NAMES, "raster op"));
307
+ }
308
+ if (node.by != null && !DRIVER_NAMES.includes(node.by)) {
309
+ errors.push(`[${i}] ` + didYouMean(node.by, DRIVER_NAMES, "driver"));
310
+ }
311
+ const p = producedField(node);
312
+ if (p) produced.add(p);
313
+ });
314
+
315
+ // Second pass: a consumer may legitimately precede its producer in the
316
+ // array, because fields resolve in the field stage before any other.
317
+ nodes.forEach((node, i) => {
318
+ const c = node && consumedField(node);
319
+ if (c && !produced.has(c)) {
320
+ errors.push(`[${i}] \`masked: ${JSON.stringify(node.masked)}\` names a field ` +
321
+ `nothing in this chain produces, so the op renders unmasked. ` +
322
+ `Fields produced here: ${[...produced].join(", ") || "(none)"}.`);
323
+ }
324
+ });
325
+
326
+ return { ok: errors.length === 0, errors };
327
+ }
328
+
329
+ export { PRESETS, presetNames, presetInfo, preset, compose, validateChain };
@@ -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
  */
package/lib/seo.js CHANGED
@@ -1,9 +1,36 @@
1
1
  /*!
2
- * nodality v1.0.221
2
+ * nodality v1.1.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
6
6
 
7
+ /*!
8
+ * nodality/seo — SEO metadata helpers
9
+ *
10
+ * Exposes a single `applySeoMeta()` call that idempotently writes a
11
+ * full per-page metadata bundle into <head>: description, canonical,
12
+ * Open Graph (Facebook / iMessage / WhatsApp / Slack / LinkedIn),
13
+ * Twitter / X card, and an optional schema.org JSON-LD payload.
14
+ *
15
+ * Also exports a handful of pre-built JSON-LD builders for the
16
+ * common schema types (Organization, WebSite, Product, Article,
17
+ * BreadcrumbList) so projects don't have to memorise the schema.org
18
+ * field names.
19
+ *
20
+ * Works in both directions:
21
+ * • At SSG time (nodality prerender) the tags land in the static
22
+ * HTML response — crawlers and social-card scrapers index them.
23
+ * • At browser hydration time the same call re-applies the tags
24
+ * against the live <head>; idempotent via `data-seo="1"` markers,
25
+ * so re-rendering the page doesn't pile up duplicates.
26
+ *
27
+ * Origin handling: pass `path` as a leading-slash route ("/" or
28
+ * "/blog/post-1") and configure the origin globally via
29
+ * `setSeoOrigin("https://example.com")` once at app boot. Falls back
30
+ * to `window.location.origin` in the browser if no origin is set;
31
+ * falls back to "" under SSG (canonical-relative).
32
+ */
33
+
7
34
  let configuredOrigin = "";
8
35
 
9
36
  export function setSeoOrigin(origin) {
package/lib/stacker.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
  */
package/lib/suggest.js ADDED
@@ -0,0 +1,66 @@
1
+ /*!
2
+ * nodality v1.1.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /*!
8
+ * suggest.js — "did you mean …?" over a closed vocabulary.
9
+ *
10
+ * The library's most expensive historical bug class is the silently
11
+ * ignored key: a typo'd option or element type that does nothing, reports
12
+ * nothing, and is found minutes later by eye. Everywhere a vocabulary is
13
+ * closed — morph's tokens/kinds/axes, ElementMapper's element types — the
14
+ * rule is the same: throw, and name the nearest valid word.
15
+ *
16
+ * Extracted from layout/morph.js (phase M1) so the mapper can use the same
17
+ * matching, and so the ~15-line DP exists once. No dependency: writing it
18
+ * is cheaper than owning one, which is the same call the allocator made.
19
+ *
20
+ * Pure — no DOM, safe to import anywhere.
21
+ */
22
+
23
+ /**
24
+ * Levenshtein edit distance, iterative two-row DP.
25
+ */
26
+ function levenshtein(a, b) {
27
+ if (a === b) return 0;
28
+ let prev = new Array(b.length + 1);
29
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
30
+ for (let i = 1; i <= a.length; i++) {
31
+ const row = [i];
32
+ for (let j = 1; j <= b.length; j++) {
33
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
34
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
35
+ }
36
+ prev = row;
37
+ }
38
+ return prev[b.length];
39
+ }
40
+
41
+ /**
42
+ * Candidates within `max` edits of `word`.
43
+ *
44
+ * Returned in the candidate list's own (declaration) order, so ties never
45
+ * depend on an unordered walk — the same determinism rule the rest of the
46
+ * expansion follows.
47
+ */
48
+ function suggest(word, candidates, max = 2) {
49
+ const w = String(word);
50
+ return candidates.filter((c) => levenshtein(w, c) <= max);
51
+ }
52
+
53
+ /**
54
+ * The prose half: "Unknown x "y". Did you mean "z"? Valid …".
55
+ * Callers that need a machine-readable report build it themselves; this is
56
+ * for the throw sites where a human is the only consumer.
57
+ */
58
+ function didYouMean(word, candidates, label = "value") {
59
+ const near = suggest(word, candidates);
60
+ let msg = `Unknown ${label} "${word}".`;
61
+ if (near.length) msg += ` Did you mean "${near[0]}"?`;
62
+ if (candidates.length) msg += ` Valid ${label}s: ${candidates.join(", ")}.`;
63
+ return msg;
64
+ }
65
+
66
+ export { levenshtein, suggest, didYouMean };
package/lib/theme.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
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.221
2
+ * nodality v1.1.0
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -0,0 +1,205 @@
1
+ /*!
2
+ * nodality v1.1.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /*!
8
+ * transition.js — driving transition progress. Phase T4.
9
+ *
10
+ * The pipeline already renders any `t` you hand it (phases T1–T3). This
11
+ * module is only about WHERE t comes from: a timeline, a scroll range, a
12
+ * drag, a test. It owns no pixels.
13
+ *
14
+ * import { progressTimeline } from "nodality/transition";
15
+ *
16
+ * const tl = progressTimeline(pipeline, { duration: 600 });
17
+ * tl.to(1); // play forward
18
+ * tl.to(0); // reverse — from wherever it is NOW
19
+ * tl.bindScroll("#section");
20
+ * tl.set(0.37); // deterministic, for tests and gestures
21
+ *
22
+ * The one property everything rests on: **retargeting starts from the
23
+ * current value, never from zero.** Interrupting a half-finished morph
24
+ * and sending it back is `to(0)`, and it continues from 0.5 rather than
25
+ * snapping. That falls out of `t` being an input rather than a timer
26
+ * (P-1) — there is no timeline state to unwind, only a number to move.
27
+ *
28
+ * Duration scales with the distance actually travelled, so a reversal
29
+ * from the midpoint takes half as long as a full play. A fixed duration
30
+ * makes short corrections feel sluggish and is the usual reason
31
+ * interruptible animation feels wrong.
32
+ */
33
+
34
+ const EASE = {
35
+ linear: (x) => x,
36
+ in: (x) => x * x * x,
37
+ out: (x) => 1 - Math.pow(1 - x, 3),
38
+ "in-out": (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
39
+ };
40
+
41
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
42
+
43
+ const prefersReducedMotion = () =>
44
+ typeof window !== "undefined" && typeof window.matchMedia === "function" &&
45
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
46
+
47
+ /**
48
+ * @param {{ setProgress(t: number): number, progress: number }} pipeline
49
+ * anything with the phase-T1 progress surface — a raster pipeline, or a
50
+ * test double.
51
+ * @param {object} [opts]
52
+ * @param {number} [opts.duration=600] ms for a FULL 0→1 traversal; a
53
+ * shorter journey takes proportionally less.
54
+ * @param {string} [opts.easing="in-out"]
55
+ * @param {boolean} [opts.respectReducedMotion=true] jump rather than
56
+ * animate when the user has asked for reduced motion. The transition
57
+ * still completes — only the motion is removed.
58
+ */
59
+ function progressTimeline(pipeline, opts = {}) {
60
+ // A null pipeline is a legitimate, documented state, not a mistake:
61
+ // applyRasterPipeline refuses to attach under prefers-reduced-motion,
62
+ // and returns null headless or without WebGL. Callers should not have
63
+ // to branch on it — the transition still needs to COMPLETE so the app
64
+ // advances, it just completes without pixels. So drive a stub.
65
+ if (!pipeline || typeof pipeline.setProgress !== "function") {
66
+ let v = 0;
67
+ pipeline = { setProgress(t) { v = Math.min(1, Math.max(0, Number(t) || 0)); return v; },
68
+ get progress() { return v; } };
69
+ }
70
+ const duration = opts.duration != null ? opts.duration : 600;
71
+ const easing = EASE[opts.easing] || EASE["in-out"];
72
+ const respectRM = opts.respectReducedMotion !== false;
73
+
74
+ let raf = 0;
75
+ let from = pipeline.progress || 0;
76
+ let to = from;
77
+ let startedAt = 0;
78
+ let span = 0; // ms this particular journey should take
79
+ let running = false;
80
+ let pending = null; // resolve of the in-flight to()
81
+ let scroll = null; // { el, onScroll }
82
+ let destroyed = false;
83
+
84
+ const settle = () => {
85
+ running = false;
86
+ raf = 0;
87
+ const done = pending;
88
+ pending = null;
89
+ if (done) done(pipeline.progress);
90
+ };
91
+
92
+ const frame = () => {
93
+ if (destroyed) return;
94
+ const elapsed = now() - startedAt;
95
+ const x = span <= 0 ? 1 : Math.min(1, elapsed / span);
96
+ pipeline.setProgress(from + (to - from) * easing(x));
97
+ if (x >= 1) return settle();
98
+ raf = requestAnimationFrame(frame);
99
+ };
100
+
101
+ const api = {
102
+ get value() { return pipeline.progress; },
103
+ get running() { return running; },
104
+
105
+ /** Jump. No animation, no easing — the deterministic entry point. */
106
+ set(t) {
107
+ api.stop();
108
+ pipeline.setProgress(t);
109
+ return pipeline.progress;
110
+ },
111
+
112
+ /**
113
+ * Animate to `target`, STARTING FROM WHEREVER IT IS. Calling this
114
+ * mid-flight retargets rather than restarting, which is the whole
115
+ * of interruption and reversal.
116
+ */
117
+ to(target, o = {}) {
118
+ if (destroyed) return Promise.resolve(pipeline.progress);
119
+ const dest = Math.min(1, Math.max(0, Number(target) || 0));
120
+
121
+ // Reduced motion: complete, do not animate. The user asked for
122
+ // no movement, not for a broken navigation.
123
+ if (respectRM && prefersReducedMotion()) {
124
+ api.set(dest);
125
+ return Promise.resolve(dest);
126
+ }
127
+
128
+ // Resolve any in-flight promise before replacing it, so an
129
+ // interrupted `await tl.to(1)` does not hang forever.
130
+ if (pending) { const p = pending; pending = null; p(pipeline.progress); }
131
+ if (raf) cancelAnimationFrame(raf);
132
+
133
+ from = pipeline.progress;
134
+ to = dest;
135
+ const dist = Math.abs(to - from);
136
+ if (dist < 1e-4) { pipeline.setProgress(dest); return Promise.resolve(dest); }
137
+ // Distance-scaled: a correction from 0.9 to 1 is brief.
138
+ span = (o.duration != null ? o.duration : duration) * dist;
139
+ startedAt = now();
140
+ running = true;
141
+ raf = requestAnimationFrame(frame);
142
+ return new Promise((res) => { pending = res; });
143
+ },
144
+
145
+ stop() {
146
+ if (raf) cancelAnimationFrame(raf);
147
+ raf = 0;
148
+ running = false;
149
+ if (pending) { const p = pending; pending = null; p(pipeline.progress); }
150
+ return pipeline.progress;
151
+ },
152
+
153
+ /**
154
+ * Scrub `t` from how far `el` has travelled through the viewport.
155
+ * Same mapping as the `scroll` driver so the two agree.
156
+ */
157
+ bindScroll(el, o = {}) {
158
+ const node = typeof el === "string" ? document.querySelector(el) : el;
159
+ if (!node) return api;
160
+ api.unbindScroll();
161
+ api.stop();
162
+ const start = o.start != null ? o.start : 0;
163
+ const end = o.end != null ? o.end : 1;
164
+ const update = () => {
165
+ if (destroyed) return;
166
+ const r = node.getBoundingClientRect();
167
+ const vh = window.innerHeight || 1;
168
+ // 0 when the element's top reaches the bottom of the
169
+ // viewport, 1 when its bottom reaches the top.
170
+ const raw = (vh - r.top) / (vh + r.height || 1);
171
+ const p = (Math.min(1, Math.max(0, raw)) - start) / ((end - start) || 1);
172
+ pipeline.setProgress(Math.min(1, Math.max(0, p)));
173
+ };
174
+ scroll = { node, update };
175
+ window.addEventListener("scroll", update, { passive: true });
176
+ window.addEventListener("resize", update, { passive: true });
177
+ update();
178
+ return api;
179
+ },
180
+
181
+ unbindScroll() {
182
+ if (!scroll) return api;
183
+ window.removeEventListener("scroll", scroll.update);
184
+ window.removeEventListener("resize", scroll.update);
185
+ scroll = null;
186
+ return api;
187
+ },
188
+
189
+ /**
190
+ * Drive from any 0..1 source — a drag, a slider, a gesture. The
191
+ * caller owns the source; this just forwards it.
192
+ */
193
+ drive(value) { return api.set(value); },
194
+
195
+ destroy() {
196
+ destroyed = true;
197
+ api.stop();
198
+ api.unbindScroll();
199
+ },
200
+ };
201
+
202
+ return api;
203
+ }
204
+
205
+ export { progressTimeline, EASE as TIMELINE_EASINGS, prefersReducedMotion };