nodality 1.0.187 → 1.0.189

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 (192) hide show
  1. package/dist/animator.cjs.js +1 -1
  2. package/dist/animator.esm.js +1 -1
  3. package/dist/audionew.cjs.js +1 -1
  4. package/dist/audionew.esm.js +1 -1
  5. package/dist/base.cjs.js +1 -1
  6. package/dist/base.esm.js +1 -1
  7. package/dist/beta-desktop-bar.cjs.js +1 -1
  8. package/dist/beta-desktop-bar.esm.js +1 -1
  9. package/dist/beta-mobile-bar.cjs.js +1 -1
  10. package/dist/beta-mobile-bar.esm.js +1 -1
  11. package/dist/bundle.umd.js +1 -1
  12. package/dist/button.cjs.js +1 -1
  13. package/dist/button.esm.js +1 -1
  14. package/dist/center.cjs.js +1 -1
  15. package/dist/center.esm.js +1 -1
  16. package/dist/checkbox.cjs.js +1 -1
  17. package/dist/checkbox.esm.js +1 -1
  18. package/dist/code.cjs.js +1 -1
  19. package/dist/code.esm.js +1 -1
  20. package/dist/container.cjs.js +1 -1
  21. package/dist/container.esm.js +1 -1
  22. package/dist/data-list.cjs.js +1 -1
  23. package/dist/data-list.esm.js +1 -1
  24. package/dist/designer.cjs.js +1 -1
  25. package/dist/designer.esm.js +1 -1
  26. package/dist/dropdown.cjs.js +1 -1
  27. package/dist/dropdown.esm.js +1 -1
  28. package/dist/element-mapper.cjs.js +1 -1
  29. package/dist/element-mapper.esm.js +1 -1
  30. package/dist/finalresult.esm.js +1 -1
  31. package/dist/flex-card.cjs.js +1 -1
  32. package/dist/flex-card.esm.js +1 -1
  33. package/dist/flex-grid.cjs.js +1 -1
  34. package/dist/flex-grid.esm.js +1 -1
  35. package/dist/flex-row.cjs.js +1 -1
  36. package/dist/flex-row.esm.js +1 -1
  37. package/dist/floating-input.cjs.js +1 -1
  38. package/dist/floating-input.esm.js +1 -1
  39. package/dist/free.cjs.js +1 -1
  40. package/dist/free.esm.js +1 -1
  41. package/dist/horizontal-scroller.cjs.js +1 -1
  42. package/dist/horizontal-scroller.esm.js +1 -1
  43. package/dist/image-picker.cjs.js +1 -1
  44. package/dist/image-picker.esm.js +1 -1
  45. package/dist/image.cjs.js +1 -1
  46. package/dist/image.esm.js +1 -1
  47. package/dist/index.cjs.js +1 -1
  48. package/dist/index.esm.js +1 -1
  49. package/dist/link-getter.cjs.js +1 -1
  50. package/dist/link-getter.esm.js +1 -1
  51. package/dist/link.cjs.js +1 -1
  52. package/dist/link.esm.js +1 -1
  53. package/dist/meta-adder.cjs.js +1 -1
  54. package/dist/meta-adder.esm.js +1 -1
  55. package/dist/modal-2025.cjs.js +1 -1
  56. package/dist/modal-2025.esm.js +1 -1
  57. package/dist/multiswitcher.cjs.js +1 -1
  58. package/dist/multiswitcher.esm.js +1 -1
  59. package/dist/new-nav-bar.cjs.js +1 -1
  60. package/dist/new-nav-bar.esm.js +1 -1
  61. package/dist/picker.cjs.js +1 -1
  62. package/dist/picker.esm.js +1 -1
  63. package/dist/progress.cjs.js +1 -1
  64. package/dist/progress.esm.js +1 -1
  65. package/dist/radio.cjs.js +1 -1
  66. package/dist/radio.esm.js +1 -1
  67. package/dist/range.cjs.js +1 -1
  68. package/dist/range.esm.js +1 -1
  69. package/dist/side-bar.cjs.js +1 -1
  70. package/dist/side-bar.esm.js +1 -1
  71. package/dist/side-nav-bar.cjs.js +1 -1
  72. package/dist/side-nav-bar.esm.js +1 -1
  73. package/dist/simple-bar.cjs.js +1 -1
  74. package/dist/simple-bar.esm.js +1 -1
  75. package/dist/slider-2025.cjs.js +1 -1
  76. package/dist/slider-2025.esm.js +1 -1
  77. package/dist/spacer.cjs.js +1 -1
  78. package/dist/spacer.esm.js +1 -1
  79. package/dist/stack.cjs.js +1 -1
  80. package/dist/stack.esm.js +1 -1
  81. package/dist/table.cjs.js +1 -1
  82. package/dist/table.esm.js +1 -1
  83. package/dist/text-field.cjs.js +1 -1
  84. package/dist/text-field.esm.js +1 -1
  85. package/dist/text.cjs.js +1 -1
  86. package/dist/text.esm.js +1 -1
  87. package/dist/ulist.cjs.js +1 -1
  88. package/dist/ulist.esm.js +1 -1
  89. package/dist/video.cjs.js +1 -1
  90. package/dist/video.esm.js +1 -1
  91. package/dist/wrap.cjs.js +1 -1
  92. package/dist/wrap.esm.js +1 -1
  93. package/dist/zoom-card.cjs.js +1 -1
  94. package/dist/zoom-card.esm.js +1 -1
  95. package/layout/animator.js +46 -12
  96. package/layout/audio.js +1 -1
  97. package/layout/audionew.js +1 -1
  98. package/layout/base-2.js +1 -1
  99. package/layout/base.js +1 -1
  100. package/layout/beta-desktop-bar.js +1 -1
  101. package/layout/beta-mobile-bar.js +1 -1
  102. package/layout/box.js +1 -1
  103. package/layout/button.js +1 -1
  104. package/layout/cards.js +1 -1
  105. package/layout/center.js +1 -1
  106. package/layout/checkbox.js +1 -1
  107. package/layout/circle.js +1 -1
  108. package/layout/clean-row.js +1 -1
  109. package/layout/code.js +1 -1
  110. package/layout/container.js +1 -1
  111. package/layout/custom.js +1 -1
  112. package/layout/div-image.js +1 -1
  113. package/layout/dropdown-2025.js +1 -1
  114. package/layout/dropdown.js +1 -1
  115. package/layout/empty-element.js +1 -1
  116. package/layout/external-stylesheet.js +1 -1
  117. package/layout/flex-card.js +1 -1
  118. package/layout/flex-grid.js +1 -1
  119. package/layout/flex-row.js +1 -1
  120. package/layout/footer.js +1 -1
  121. package/layout/form-components/custom.js +1 -1
  122. package/layout/form-components/data-list.js +1 -1
  123. package/layout/form-components/floating-input.js +1 -1
  124. package/layout/form-components/form-all.js +1 -1
  125. package/layout/form-components/form.js +1 -1
  126. package/layout/form-components/image-picker.js +1 -1
  127. package/layout/form-components/picker.js +1 -1
  128. package/layout/form-components/radio.js +1 -1
  129. package/layout/form-components/radiogroup.js +1 -1
  130. package/layout/form-components/range.js +1 -1
  131. package/layout/free.js +1 -1
  132. package/layout/grid-new.js +1 -1
  133. package/layout/grid-switcher.js +1 -1
  134. package/layout/grid.js +1 -1
  135. package/layout/group.js +1 -1
  136. package/layout/header.js +1 -1
  137. package/layout/horizontal-scroller.js +1 -1
  138. package/layout/image-old.js +1 -1
  139. package/layout/image.js +1 -1
  140. package/layout/index.js +1 -1
  141. package/layout/label.js +1 -1
  142. package/layout/link.js +1 -1
  143. package/layout/list-OLD.js +1 -1
  144. package/layout/list.js +1 -1
  145. package/layout/meta-adder.js +1 -1
  146. package/layout/modal-2025.js +1 -1
  147. package/layout/modernwrap.js +1 -1
  148. package/layout/multiswitcher.js +1 -1
  149. package/layout/multiswitcherBeta.js +1 -1
  150. package/layout/nav-bar.js +1 -1
  151. package/layout/nav-factor/custom-div.js +1 -1
  152. package/layout/navBar-OLD.js +1 -1
  153. package/layout/new-flat-adder.js +1 -1
  154. package/layout/new-nav-bar.js +84 -8
  155. package/layout/offset-container.js +1 -1
  156. package/layout/polygon.js +1 -1
  157. package/layout/prerender-site.js +1 -1
  158. package/layout/prerender.js +1 -1
  159. package/layout/progress.js +1 -1
  160. package/layout/row.js +1 -1
  161. package/layout/saved-new-nav-bar.js +1 -1
  162. package/layout/scroll-video.js +1 -1
  163. package/layout/side-bar.js +1 -1
  164. package/layout/side-nav-bar.js +1 -1
  165. package/layout/simple-bar.js +1 -1
  166. package/layout/slider-2025.js +1 -1
  167. package/layout/spacer.js +1 -1
  168. package/layout/stack.js +1 -1
  169. package/layout/styler.js +1 -1
  170. package/layout/svg.js +1 -1
  171. package/layout/switcher.js +1 -1
  172. package/layout/table.js +1 -1
  173. package/layout/text-field.js +1 -1
  174. package/layout/text.js +1 -1
  175. package/layout/ulist.js +1 -1
  176. package/layout/video.js +1 -1
  177. package/layout/without-new.js +1 -1
  178. package/layout/wrap.js +1 -1
  179. package/layout/zoom-card.js +1 -1
  180. package/lib/card-getter.js +1 -1
  181. package/lib/data.js +1 -1
  182. package/lib/designer.js +1 -1
  183. package/lib/element-mapper.js +1 -1
  184. package/lib/keyframe-animation.js +1 -1
  185. package/lib/link-getter.js +1 -1
  186. package/lib/raster-ops.js +556 -46
  187. package/lib/scroll-video.js +1 -1
  188. package/lib/seo.js +1 -1
  189. package/lib/stacker.js +1 -1
  190. package/lib/theme.js +1 -1
  191. package/lib/transform-anim.js +1 -1
  192. package/package.json +1 -1
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.187
2
+ * nodality v1.0.189
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -57,6 +57,10 @@ const MAX_BLOBS = 12;
57
57
  // 0..1 that the op scales its effect by. Ops read them as u<i>_dpos and
58
58
  // u<i>_damt; the pipeline evaluates and uploads them once per frame.
59
59
  const DRIVERS = {
60
+ // Centre of the element at full strength. The default for ops that
61
+ // need a focus point but were not given a `by:` -- without it their
62
+ // dpos/damt would never be uploaded and would read as zero.
63
+ static: (c) => ({ x: c.w * 0.5, y: c.h * 0.5, amt: 1 }),
60
64
  mouse: (c) => ({ x: c.mouseX, y: c.mouseY, amt: 1 }),
61
65
  // Same focus as mouse, but faded in and out with the hover state, so
62
66
  // the effect resolves away when the pointer leaves.
@@ -259,6 +263,280 @@ const REGISTRY = {
259
263
  }),
260
264
  },
261
265
 
266
+ // ── Field producers ──────────────────────────────────────────────
267
+ // These draw nothing. They write a scalar field that LATER ops read
268
+ // via `masked:`, which is the whole of the node-to-node data flow:
269
+ //
270
+ // { op: "mask", from: "radial", as: "centre", radius: 300 },
271
+ // { op: "stir", masked: "centre" }, // fluid only in the middle
272
+ // { op: "halftone" }, // screen everywhere
273
+ //
274
+ // The masking wrapper is generic, so an op does not know or care
275
+ // that it is being constrained — every op, including ones written
276
+ // before fields existed, can be masked.
277
+
278
+ // Geometric / content masks.
279
+ mask: {
280
+ stage: "field",
281
+ producesField: true,
282
+ defaultDriver: "static",
283
+ decl: (p) => `
284
+ uniform vec2 ${p}dpos;
285
+ uniform float ${p}damt;
286
+ uniform float ${p}radius;
287
+ uniform vec2 ${p}at;
288
+ uniform vec2 ${p}remap;
289
+ uniform float ${p}invert;`,
290
+ code: (p, node) => {
291
+ const src = node.from || "radial";
292
+ // Where a radial mask sits. `at:` is fractional (0..1 of the
293
+ // element box) so it survives resizes; without it the focus
294
+ // comes from the driver, which defaults to the centre.
295
+ const ctr = node.at ? `(${p}at * u_res)` : `${p}dpos`;
296
+ const raw = src === "vertical"
297
+ ? `frag.y / max(u_res.y, 1.0)`
298
+ : src === "horizontal"
299
+ ? `frag.x / max(u_res.x, 1.0)`
300
+ : /* radial, and the default */
301
+ `1.0 - smoothstep(0.0, ${p}radius, length(frag - ${ctr}))`;
302
+ return `
303
+ {
304
+ float m = ${raw};
305
+ m = clamp((m - ${p}remap.x) / max(${p}remap.y - ${p}remap.x, 1e-4), 0.0, 1.0);
306
+ m = mix(m, 1.0 - m, ${p}invert);
307
+ ${fieldVar(node.as || "mask")} *= m * ${p}damt;
308
+ }`;
309
+ },
310
+ uniforms: (node, dpr) => ({
311
+ radius: ["1f", (node.radius != null ? node.radius : 260) * dpr],
312
+ at: ["2fv", node.at || [0.5, 0.5]],
313
+ remap: ["2fv", node.remap || [0, 1]],
314
+ invert: ["1f", node.invert ? 1 : 0],
315
+ }),
316
+ },
317
+
318
+ // Animated value noise as a field. The Houdini move: make an
319
+ // attribute, then reference it downstream.
320
+ //
321
+ // { op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
322
+ // { op: "offset", masked: "turbulence", strength: 30 },
323
+ noise: {
324
+ stage: "field",
325
+ producesField: true,
326
+ decl: (p) => `
327
+ uniform float ${p}scale;
328
+ uniform float ${p}speed;
329
+ uniform vec2 ${p}remap;
330
+ uniform float ${p}amp;
331
+ float ${p}h(vec2 v) {
332
+ return fract(sin(dot(v, vec2(41.113, 289.717))) * 43758.545);
333
+ }
334
+ float ${p}n(vec2 v) {
335
+ vec2 i = floor(v), f = v - i;
336
+ f = f * f * (3.0 - 2.0 * f);
337
+ return mix(mix(${p}h(i), ${p}h(i + vec2(1.0, 0.0)), f.x),
338
+ mix(${p}h(i + vec2(0.0, 1.0)), ${p}h(i + vec2(1.0, 1.0)), f.x), f.y);
339
+ }`,
340
+ code: (p, node) => `
341
+ {
342
+ vec2 np = frag / max(u_res.y, 1.0) * ${p}scale;
343
+ // Two octaves: enough structure to read as turbulence
344
+ // without the cost of a full fBm.
345
+ float n = ${p}n(np + u_time * ${p}speed) * 0.65 +
346
+ ${p}n(np * 2.7 - u_time * ${p}speed * 0.6) * 0.35;
347
+ n = clamp((n - ${p}remap.x) / max(${p}remap.y - ${p}remap.x, 1e-4), 0.0, 1.0);
348
+ ${fieldVar(node.as || "mask")} *= mix(1.0, n, ${p}amp);
349
+ }`,
350
+ uniforms: (node) => ({
351
+ scale: ["1f", node.scale != null ? node.scale : 3],
352
+ speed: ["1f", node.speed != null ? node.speed : 0.3],
353
+ remap: ["2fv", node.remap || [0, 1]],
354
+ amp: ["1f", node.amount != null ? node.amount : 1],
355
+ }),
356
+ },
357
+
358
+ // Copy to points. The content is re-sampled at a set of points, each
359
+ // stamp scaled and rotated, and composited into the overlay layer so
360
+ // the copies sit above the original rather than replacing it.
361
+ //
362
+ // { op: "copy", count: 6, radius: 120, scale: 0.55, rotate: 0.3 }
363
+ // { op: "copy", points: [[0.2, 0.5], [0.8, 0.5]], scale: 0.4 }
364
+ //
365
+ // With `count`, the points are a ring around the driver focus, so
366
+ // `by: "mouse"` drags the whole instance set around. With explicit
367
+ // `points`, positions are fractional (0..1 of the element box) and
368
+ // survive resizes.
369
+ //
370
+ // Single pass, so the stamp count is fixed at compile time and the
371
+ // loop is unrolled: `count` is a structural parameter, not something
372
+ // to animate. Copies sample the ORIGINAL texture, not the result of
373
+ // earlier colour ops, so a copy of a halftoned element is a copy of
374
+ // the unhalftoned source with the screen applied over it.
375
+ copy: {
376
+ stage: "color",
377
+ defaultDriver: "static",
378
+ decl: (p) => `
379
+ uniform vec2 ${p}dpos;
380
+ uniform float ${p}damt;
381
+ uniform float ${p}radius;
382
+ uniform float ${p}scale;
383
+ uniform float ${p}rotate;
384
+ uniform float ${p}fade;
385
+ uniform float ${p}amount;`,
386
+ code: (p, node) => {
387
+ const explicit = Array.isArray(node.points) && node.points.length > 0;
388
+ const n = explicit
389
+ ? node.points.length
390
+ : Math.max(1, Math.min(24, Math.round(node.count || 5)));
391
+ let body = "";
392
+ for (let k = 0; k < n; k++) {
393
+ // Where this stamp is centred.
394
+ const centre = explicit
395
+ ? `vec2(${(+node.points[k][0]).toFixed(5)}, ${(+node.points[k][1]).toFixed(5)}) * u_res`
396
+ : (() => {
397
+ const a = (k / n) * Math.PI * 2;
398
+ return `${p}dpos + vec2(${Math.cos(a).toFixed(5)}, ` +
399
+ `${Math.sin(a).toFixed(5)}) * ${p}radius`;
400
+ })();
401
+ body += `
402
+ {
403
+ float ang = ${p}rotate * ${(k + 1).toFixed(1)};
404
+ float cs = cos(ang), sn = sin(ang);
405
+ // Inverse transform: to draw a stamp scaled by s about a
406
+ // centre c, fetch from the source at (frag - c) / s.
407
+ vec2 d = mat2(cs, -sn, sn, cs) * (frag - (${centre}));
408
+ vec2 q = d / max(${p}scale, 1e-3) + u_res * 0.5;
409
+ vec2 quv = vec2(q.x / u_res.x, 1.0 - q.y / u_res.y);
410
+ vec2 inside = step(vec2(0.0), quv) * step(quv, vec2(1.0));
411
+ vec4 s = texture2D(u_tex, clamp(quv, 0.0, 1.0));
412
+ float w = s.a * inside.x * inside.y * ${p}amount * ${p}damt
413
+ * pow(${p}fade, ${k.toFixed(1)});
414
+ // Straight-alpha OVER, newest stamp on top.
415
+ float na = w + ovA * (1.0 - w);
416
+ ovCol = (s.rgb * w + ovCol * ovA * (1.0 - w)) / max(na, 1e-4);
417
+ ovA = na;
418
+ }`;
419
+ }
420
+ return body;
421
+ },
422
+ uniforms: (node, dpr) => ({
423
+ radius: ["1f", (node.radius != null ? node.radius : 110) * dpr],
424
+ scale: ["1f", node.scale != null ? node.scale : 0.5],
425
+ rotate: ["1f", node.rotate != null ? node.rotate : 0],
426
+ fade: ["1f", node.fade != null ? node.fade : 1],
427
+ amount: ["1f", node.amount != null ? node.amount : 1],
428
+ }),
429
+ },
430
+
431
+ // Merge two sub-chains and blend the results.
432
+ //
433
+ // { op: "merge", mode: "screen", mix: 1.0,
434
+ // a: [ { op: "halftone" } ],
435
+ // b: [ { op: "duotone", colors: [...] } ] }
436
+ //
437
+ // Both branches start from the same state and neither sees the
438
+ // other's output — that is the difference between a merge and a
439
+ // chain. `mix` fades the whole merge back toward branch A, so
440
+ // `mix: 0` is A alone and `mix: 1` is the full blend.
441
+ //
442
+ // The shader work is emitted inline, so a merge costs no extra pass,
443
+ // no extra framebuffer and no second capture of the DOM. It cannot,
444
+ // for the same reason, give the branches different source content.
445
+ merge: {
446
+ // Never routed through the normal stage path — emitMerge()
447
+ // handles it — but it still needs decl/uniforms so its `mix`
448
+ // gets declared and uploaded like any other op's.
449
+ stage: "color",
450
+ decl: (p) => `uniform float ${p}mix;`,
451
+ code: () => "",
452
+ uniforms: (node) => ({ mix: ["1f", node.mix != null ? node.mix : 1] }),
453
+ },
454
+
455
+ // Echo: temporal accumulation. Each frame is blended into a
456
+ // persistent full-resolution buffer, so content that CHANGES leaves a
457
+ // fading trail behind it — typing smears, a counter ticking over
458
+ // leaves a comet, a scrolling list ghosts.
459
+ //
460
+ // This is the one op that cannot work on the snapshot backend. A
461
+ // snapshot is a single frozen image, so accumulating it converges to
462
+ // that same still frame and nothing moves; it needs the live
463
+ // HTML-in-Canvas capture, where the texture is re-uploaded from the
464
+ // real DOM on every paint. Run it with the origin trial off and you
465
+ // correctly see nothing happen.
466
+ //
467
+ // { op: "echo", decay: 0.92, strength: 0.85 }
468
+ //
469
+ // The trail is written into the BORDER layer, which the frame
470
+ // skeleton composites underneath the content, so the ghosts sit
471
+ // behind the live text rather than veiling it.
472
+ echo: {
473
+ stage: "color",
474
+ decl: (p) => `
475
+ uniform sampler2D ${p}hist;
476
+ uniform float ${p}strength;
477
+ uniform vec3 ${p}tint;
478
+ uniform float ${p}tintAmt;`,
479
+ code: (p) => `
480
+ {
481
+ // Sampled in screen space: the trail records where things
482
+ // WERE, which is not where a warp op would fetch them from.
483
+ vec2 huv = vec2(frag.x / u_res.x, 1.0 - frag.y / u_res.y);
484
+ vec4 h = texture2D(${p}hist, clamp(huv, 0.001, 0.999));
485
+ float ha = clamp(h.a * ${p}strength, 0.0, 1.0);
486
+ vec3 hc = mix(h.rgb, ${p}tint, ${p}tintAmt);
487
+ // Straight-alpha OVER into the border layer so an edges op
488
+ // in the same chain still composites correctly.
489
+ float na = ha + edgeCov * (1.0 - ha);
490
+ edgeCol = (hc * ha + edgeCol * edgeCov * (1.0 - ha)) / max(na, 1e-4);
491
+ edgeCov = na;
492
+ }`,
493
+ uniforms: (node) => ({
494
+ strength: ["1f", node.strength != null ? node.strength : 0.85],
495
+ tint: ["3fv", hexToRgb(node.tint || "#7FD4FF")],
496
+ tintAmt: ["1f", node.tint ? (node.tintAmount != null ? node.tintAmount : 0.5) : 0.0],
497
+ }),
498
+ solver: {
499
+ // Full canvas resolution: a trail wants to be as sharp as the
500
+ // content it is trailing, not a coarse simulation grid.
501
+ resolutions: (node, w, h) => ({ frame: [w, h] }),
502
+ targets: { history: { double: true, res: "frame", fmt: "rgba", smooth: true } },
503
+ samplers: { hist: "history" },
504
+ programs: {
505
+ // out = max(content, history * decay)
506
+ //
507
+ // max rather than a mix: the brightest thing that has
508
+ // passed through a pixel is what persists, so trails read
509
+ // as light left behind instead of a muddy average. decay
510
+ // is per-frame and normalised for frame rate.
511
+ accumulate: `
512
+ precision highp float;
513
+ uniform sampler2D u_content;
514
+ uniform sampler2D u_hist;
515
+ uniform vec2 u_texel;
516
+ uniform float u_decay;
517
+ void main() {
518
+ vec2 uv = gl_FragCoord.xy * u_texel;
519
+ vec4 c = texture2D(u_content, uv);
520
+ vec4 h = texture2D(u_hist, uv) * u_decay;
521
+ gl_FragColor = max(c, h);
522
+ }`,
523
+ },
524
+ // echo takes no pointer input, so nothing to splat.
525
+ splatColor: () => [0, 0, 0],
526
+ step: (S, node, ctx) => {
527
+ const dt = Math.min(ctx.dt, 1 / 30);
528
+ const decay = Math.pow(node.decay != null ? node.decay : 0.92, dt * 60);
529
+ S.use("accumulate");
530
+ S.f2("u_texel", 1 / S.canvasW, 1 / S.canvasH);
531
+ S.f1("u_decay", decay);
532
+ S.smp("u_content", S.content, 0);
533
+ S.smp("u_hist", S.read("history"), 1);
534
+ S.blit(S.write("history"));
535
+ S.swap("history");
536
+ },
537
+ },
538
+ },
539
+
262
540
  // Stirred liquid: an incompressible fluid whose velocity field warps
263
541
  // the content and whose dye field colours it.
264
542
  //
@@ -864,7 +1142,11 @@ function hexToRgb(c) {
864
1142
  // Names designer.js uses to route nodes into this pipeline instead of
865
1143
  // the CSS-level op loop. Kept as a live array so registerRasterOp()
866
1144
  // extends routing too.
867
- const RASTER_OP_NAMES = Object.keys(REGISTRY);
1145
+ // `switch` has no REGISTRY entry — it is resolved to a node list before
1146
+ // the shader is built — but it still has to be routed to elements like
1147
+ // any other raster node, so it is named here explicitly. Only the switch
1148
+ // node carries `target:`; the sub-chains inherit whatever it matched.
1149
+ const RASTER_OP_NAMES = Object.keys(REGISTRY).concat("switch");
868
1150
 
869
1151
  // Extension surface — mirrors the CSS-level operation registry:
870
1152
  // registerRasterOp("pixelate", { stage, decl, code, uniforms }).
@@ -882,25 +1164,165 @@ function isHTMLInCanvasAvailable() {
882
1164
 
883
1165
  const VS = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;
884
1166
 
885
- function buildFragmentShader(nodes) {
886
- let decls = "", warp = "", cell = "", displace = "", color = "";
887
- nodes.forEach((node, i) => {
1167
+ // What each stage is allowed to modify. Masking works by snapshotting
1168
+ // these before an op runs and lerping back toward the snapshot by the
1169
+ // mask value, so ANY op can be masked without the op knowing about it.
1170
+ const STAGE_VARS = {
1171
+ warp: ["warped"],
1172
+ cell: ["center", "edge"],
1173
+ displace: ["sampleP", "chroma"],
1174
+ color: ["col", "edgeCol", "edgeCov", "ovCol", "ovA"],
1175
+ };
1176
+
1177
+ // A node opts into a field with `masked: true` (the default field) or
1178
+ // `masked: "name"`. Field producers declare `as:` to name what they
1179
+ // write. This is the whole of the node-to-node data flow: one op writes
1180
+ // a scalar field, later ops read it.
1181
+ // No double underscores anywhere: GLSL reserves identifiers
1182
+ // containing `__` as possible future keywords, and Chrome rejects
1183
+ // the shader outright rather than warning.
1184
+ const fieldVar = (name) => `fld_${String(name).replace(/[^A-Za-z0-9_]/g, "")}`;
1185
+ const maskedField = (node) =>
1186
+ node.masked === true ? "mask" : (typeof node.masked === "string" ? node.masked : null);
1187
+
1188
+ const glslType = (v) =>
1189
+ (v === "edge" || v === "edgeCov" || v === "ovA") ? "float"
1190
+ : (v === "col" || v === "edgeCol" || v === "ovCol") ? "vec3"
1191
+ : "vec2";
1192
+
1193
+ // How `merge` combines its two branches. Applied to the vec3 stage
1194
+ // variables only; the scalars and coordinates are plainly interpolated,
1195
+ // because "screen" is meaningless for a displacement.
1196
+ const BLEND = {
1197
+ over: (a, b) => b,
1198
+ add: (a, b) => `min(${a} + ${b}, vec3(1.0))`,
1199
+ screen: (a, b) => `1.0 - (1.0 - ${a}) * (1.0 - ${b})`,
1200
+ multiply: (a, b) => `${a} * ${b}`,
1201
+ difference: (a, b) => `abs(${a} - ${b})`,
1202
+ lighten: (a, b) => `max(${a}, ${b})`,
1203
+ darken: (a, b) => `min(${a}, ${b})`,
1204
+ };
1205
+
1206
+ const emptyBuckets = () =>
1207
+ ({ field: "", warp: "", cell: "", displace: "", color: "" });
1208
+
1209
+ // Flatten a node tree (merge nodes carry sub-chains) into the linear
1210
+ // list that owns the uniform slots. Depth-first, parent before children,
1211
+ // so `u<i>_` prefixes are stable and every consumer — shader emission,
1212
+ // uniform upload, drivers, solvers — agrees on which node is index i.
1213
+ // Returns records rather than mutating the caller's node objects.
1214
+ function flattenRaster(nodes, flat) {
1215
+ const recs = [];
1216
+ for (const node of (nodes || [])) {
1217
+ if (!node || !node.op) continue;
1218
+ const rec = { node: node, i: flat.length };
1219
+ flat.push(node);
1220
+ if (node.op === "merge") {
1221
+ rec.a = flattenRaster(node.a, flat);
1222
+ rec.b = flattenRaster(node.b, flat);
1223
+ }
1224
+ recs.push(rec);
1225
+ }
1226
+ return recs;
1227
+ }
1228
+
1229
+ function emitStages(recs, buckets) {
1230
+ for (const rec of recs) {
1231
+ const node = rec.node;
1232
+ if (node.op === "merge") { emitMerge(rec, buckets); continue; }
888
1233
  const def = REGISTRY[node.op];
889
- if (!def) return;
890
- const p = `u${i}_`;
891
- decls += def.decl(p, node) + "\n";
1234
+ if (!def) continue;
1235
+ const p = `u${rec.i}_`;
892
1236
  // An op may contribute to more than one stage (stage: ["warp",
893
1237
  // "color"]) — code() then receives the stage it is emitting for.
894
1238
  // A plain string stage keeps the original single-snippet form.
895
1239
  const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
1240
+ const mask = maskedField(node);
896
1241
  stages.forEach((st) => {
897
- const snippet = def.code(p, node, st) + "\n";
898
- if (st === "warp") warp += snippet;
899
- else if (st === "cell") cell += snippet;
900
- else if (st === "displace") displace += snippet;
901
- else color += snippet;
1242
+ let snippet = def.code(p, node, st) + "\n";
1243
+ if (mask && STAGE_VARS[st]) {
1244
+ // Generic masking wrapper: no op needs to know it is
1245
+ // being masked, which is what lets a field constrain
1246
+ // ops written long before fields existed.
1247
+ const vars = STAGE_VARS[st];
1248
+ const snap = vars.map((v, k) =>
1249
+ ` ${glslType(v)} msv${k} = ${v};`).join("\n");
1250
+ const back = vars.map((v, k) =>
1251
+ ` ${v} = mix(msv${k}, ${v}, ${fieldVar(mask)});`).join("\n");
1252
+ snippet = ` {\n${snap}\n${snippet}\n${back}\n }\n`;
1253
+ }
1254
+ // `field` has no STAGE_VARS entry (it writes named fields,
1255
+ // not stage variables), so it is listed explicitly here —
1256
+ // anything unrecognised still falls through to colour.
1257
+ const bucket = (st === "field" || st === "warp" ||
1258
+ st === "cell" || st === "displace") ? st : "color";
1259
+ buckets[bucket] += snippet;
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ // merge: run two sub-chains from the same starting state and combine
1265
+ // them. Still one pass — each branch's stage code is emitted into its
1266
+ // own scope, the stage variables are rewound between branches, and the
1267
+ // two results are blended. That is what makes it free: no second
1268
+ // framebuffer, no second capture of the DOM.
1269
+ //
1270
+ // The consequence is that a branch cannot see the other's output, only
1271
+ // the shared input, which is exactly the semantics you want for a merge
1272
+ // and not the semantics of a chain.
1273
+ function emitMerge(rec, buckets) {
1274
+ const p = `u${rec.i}_`;
1275
+ const blend = BLEND[rec.node.mode] || BLEND.over;
1276
+ const A = emptyBuckets(), B = emptyBuckets();
1277
+ emitStages(rec.a || [], A);
1278
+ emitStages(rec.b || [], B);
1279
+ // Fields are shared state, not branch-local: a mask written inside
1280
+ // one branch is visible to the other, and to everything after.
1281
+ buckets.field += A.field + B.field;
1282
+ for (const st of ["warp", "cell", "displace", "color"]) {
1283
+ if (!A[st] && !B[st]) continue;
1284
+ const vars = STAGE_VARS[st];
1285
+ let s = " {\n";
1286
+ vars.forEach((v, k) => { s += ` ${glslType(v)} pre${k} = ${v};\n`; });
1287
+ s += A[st];
1288
+ vars.forEach((v, k) => { s += ` ${glslType(v)} bra${k} = ${v};\n`; });
1289
+ vars.forEach((v, k) => { s += ` ${v} = pre${k};\n`; });
1290
+ s += B[st];
1291
+ vars.forEach((v, k) => {
1292
+ const combined = glslType(v) === "vec3"
1293
+ ? blend(`bra${k}`, v)
1294
+ : v;
1295
+ s += ` ${v} = mix(bra${k}, ${combined}, ${p}mix);\n`;
902
1296
  });
1297
+ s += " }\n";
1298
+ buckets[st] += s;
1299
+ }
1300
+ }
1301
+
1302
+ function buildFragmentShader(recs, flat) {
1303
+ let decls = "";
1304
+
1305
+ // Every field any node writes or reads, declared up front at 1.0 so
1306
+ // an unmatched reference is a no-op rather than a compile error.
1307
+ const fieldNames = new Set();
1308
+ flat.forEach((n) => {
1309
+ const def = REGISTRY[n.op];
1310
+ if (def && def.producesField) fieldNames.add(n.as || "mask");
1311
+ const m = maskedField(n);
1312
+ if (m) fieldNames.add(m);
1313
+ });
1314
+ const fieldDecls = [...fieldNames]
1315
+ .map((n) => ` float ${fieldVar(n)} = 1.0;`).join("\n");
1316
+
1317
+ flat.forEach((node, i) => {
1318
+ const def = REGISTRY[node.op];
1319
+ if (def) decls += def.decl(`u${i}_`, node) + "\n";
903
1320
  });
1321
+
1322
+ const buckets = emptyBuckets();
1323
+ emitStages(recs, buckets);
1324
+ const { field, warp, cell, displace, color } = buckets;
1325
+ const nodes = flat;
904
1326
  // Compositing, in three layers (all straight-alpha):
905
1327
  // 1. border layer (edgeCol/edgeCov, from an edges op) UNDER
906
1328
  // 2. content layer (col over tex.a) then
@@ -926,6 +1348,8 @@ uniform float u_time;
926
1348
  ${decls}
927
1349
  void main() {
928
1350
  vec2 frag = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
1351
+ ${fieldDecls}
1352
+ ${field}
929
1353
  vec2 warped = frag;
930
1354
  ${warp}
931
1355
  vec2 center = warped;
@@ -999,6 +1423,59 @@ function snapshotToImage(el, w, h, dpr) {
999
1423
  });
1000
1424
  }
1001
1425
 
1426
+ // ── switch: pick a sub-chain by condition ────────────────────────────
1427
+ //
1428
+ // { op: "switch",
1429
+ // when: "(max-width: 700px)", // or "coarse", "fine", "no-hover"
1430
+ // use: [ { op: "halftone" } ], // taken when the test passes
1431
+ // else: [ { op: "stir" }, ... ] } // taken when it does not
1432
+ //
1433
+ // This is a build-time node, not a shader op: it produces a node list,
1434
+ // which is then compiled like any other. Sub-chains nest, so a switch
1435
+ // may contain a switch.
1436
+ //
1437
+ // Evaluated ONCE, when the pipeline attaches. It is meant for choices
1438
+ // that are properties of the device — a cheap chain on a phone, a heavy
1439
+ // one on a desktop — not for anything that should change as the window
1440
+ // is dragged: switching mid-session would need a new shader program,
1441
+ // and the pipeline compiles exactly one.
1442
+ const SWITCH_ALIASES = {
1443
+ coarse: "(pointer: coarse)",
1444
+ fine: "(pointer: fine)",
1445
+ hover: "(hover: hover)",
1446
+ "no-hover": "(hover: none)",
1447
+ "reduced-motion": "(prefers-reduced-motion: reduce)",
1448
+ dark: "(prefers-color-scheme: dark)",
1449
+ light: "(prefers-color-scheme: light)",
1450
+ };
1451
+
1452
+ function switchTest(when) {
1453
+ if (typeof when === "function") {
1454
+ try { return !!when(); } catch (e) { return false; }
1455
+ }
1456
+ if (typeof when === "boolean") return when;
1457
+ if (typeof when !== "string" || when === "") return false;
1458
+ const q = SWITCH_ALIASES[when] || when;
1459
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
1460
+ try { return window.matchMedia(q).matches; } catch (e) { return false; }
1461
+ }
1462
+
1463
+ function resolveSwitches(nodes, depth) {
1464
+ if (!Array.isArray(nodes)) return [];
1465
+ // Cheap cycle guard: sub-chains are plain data and could be shared
1466
+ // by reference, so a self-referential list would otherwise hang.
1467
+ if ((depth || 0) > 8) return [];
1468
+ const out = [];
1469
+ for (const node of nodes) {
1470
+ if (!node || node.op !== "switch") { out.push(node); continue; }
1471
+ const branch = switchTest(node.when) ? node.use : node.else;
1472
+ // A branch may legitimately be empty — that is how you say
1473
+ // "do nothing on small screens".
1474
+ for (const sub of resolveSwitches(branch || [], (depth || 0) + 1)) out.push(sub);
1475
+ }
1476
+ return out;
1477
+ }
1478
+
1002
1479
  // ── Pipeline runner ──────────────────────────────────────────────────
1003
1480
 
1004
1481
  function applyRasterPipeline(el, rasterNodes) {
@@ -1007,6 +1484,20 @@ function applyRasterPipeline(el, rasterNodes) {
1007
1484
  // page untouched.
1008
1485
  if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
1009
1486
  if (!rasterNodes || rasterNodes.length === 0) return null;
1487
+ // Flatten any switch nodes down to the chain this device actually
1488
+ // gets, before anything reads the list.
1489
+ if (rasterNodes.some((n) => n && n.op === "switch")) {
1490
+ rasterNodes = resolveSwitches(rasterNodes, 0);
1491
+ if (rasterNodes.length === 0) return null;
1492
+ }
1493
+ // Flatten merge sub-chains into the linear list that owns the
1494
+ // uniform slots. `tree` keeps the branch structure for the shader
1495
+ // emitter; `flat` is what every other loop iterates, so a node
1496
+ // nested inside a merge gets its uniforms, driver and solver set up
1497
+ // exactly like a top-level one.
1498
+ const flatNodes = [];
1499
+ const rasterTree = flattenRaster(rasterNodes, flatNodes);
1500
+ rasterNodes = flatNodes;
1010
1501
  if (typeof window.matchMedia === "function" &&
1011
1502
  window.matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
1012
1503
 
@@ -1147,7 +1638,7 @@ function applyRasterPipeline(el, rasterNodes) {
1147
1638
  };
1148
1639
  const prog = gl.createProgram();
1149
1640
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
1150
- gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterNodes)));
1641
+ gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterTree, rasterNodes)));
1151
1642
  // Pin "a" to location 0 so a field-simulation program (which shares
1152
1643
  // this vertex buffer and attribute array) can be swapped in without
1153
1644
  // respecifying the pointer.
@@ -1206,7 +1697,9 @@ function applyRasterPipeline(el, rasterNodes) {
1206
1697
  return null;
1207
1698
  }
1208
1699
  gl.getExtension("OES_texture_float_linear");
1209
- const res = def.solver.resolutions(node);
1700
+ // Canvas dimensions are passed through so a target can be
1701
+ // full-resolution (echo's history) rather than a fixed grid.
1702
+ const res = def.solver.resolutions(node, canvas.width, canvas.height);
1210
1703
  const FMT = {
1211
1704
  r: [gl.R16F, gl.RED],
1212
1705
  rg: [gl.RG16F, gl.RG],
@@ -1214,7 +1707,7 @@ function applyRasterPipeline(el, rasterNodes) {
1214
1707
  };
1215
1708
 
1216
1709
  const made = [];
1217
- const mkTarget = (size, fmt, smooth) => {
1710
+ const mkTarget = (w, h, fmt, smooth) => {
1218
1711
  const [internal, format] = FMT[fmt];
1219
1712
  const t = gl.createTexture();
1220
1713
  gl.bindTexture(gl.TEXTURE_2D, t);
@@ -1223,17 +1716,17 @@ function applyRasterPipeline(el, rasterNodes) {
1223
1716
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, flt);
1224
1717
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1225
1718
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1226
- gl.texImage2D(gl.TEXTURE_2D, 0, internal, size, size, 0,
1719
+ gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0,
1227
1720
  format, gl.HALF_FLOAT, null);
1228
1721
  const fbo = gl.createFramebuffer();
1229
1722
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1230
1723
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
1231
1724
  gl.TEXTURE_2D, t, 0);
1232
1725
  const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
1233
- gl.viewport(0, 0, size, size);
1726
+ gl.viewport(0, 0, w, h);
1234
1727
  gl.clearColor(0, 0, 0, 1);
1235
1728
  gl.clear(gl.COLOR_BUFFER_BIT);
1236
- const rec = { tex: t, fbo, size, ok };
1729
+ const rec = { tex: t, fbo, w, h, ok };
1237
1730
  made.push(rec);
1238
1731
  return rec;
1239
1732
  };
@@ -1242,15 +1735,16 @@ function applyRasterPipeline(el, rasterNodes) {
1242
1735
  let allOk = true;
1243
1736
  for (const name in def.solver.targets) {
1244
1737
  const spec = def.solver.targets[name];
1245
- const size = res[spec.res];
1738
+ const dim = res[spec.res];
1739
+ const [tw, th] = Array.isArray(dim) ? dim : [dim, dim];
1246
1740
  if (spec.double) {
1247
1741
  targets[name] = {
1248
- a: mkTarget(size, spec.fmt, spec.smooth),
1249
- b: mkTarget(size, spec.fmt, spec.smooth),
1742
+ a: mkTarget(tw, th, spec.fmt, spec.smooth),
1743
+ b: mkTarget(tw, th, spec.fmt, spec.smooth),
1250
1744
  };
1251
1745
  allOk = allOk && targets[name].a.ok && targets[name].b.ok;
1252
1746
  } else {
1253
- targets[name] = { a: mkTarget(size, spec.fmt, spec.smooth) };
1747
+ targets[name] = { a: mkTarget(tw, th, spec.fmt, spec.smooth) };
1254
1748
  allOk = allOk && targets[name].a.ok;
1255
1749
  }
1256
1750
  }
@@ -1325,13 +1819,18 @@ function applyRasterPipeline(el, rasterNodes) {
1325
1819
  read: (name) => s.targets[name].a,
1326
1820
  write: (name) => s.targets[name].b,
1327
1821
  single: (name) => s.targets[name].a,
1822
+ // The captured page content, as a bindable target. echo
1823
+ // accumulates from it; stir never touches it.
1824
+ content: { tex },
1825
+ canvasW: canvas.width,
1826
+ canvasH: canvas.height,
1328
1827
  swap(name) {
1329
1828
  const t = s.targets[name];
1330
1829
  const tmp = t.a; t.a = t.b; t.b = tmp;
1331
1830
  },
1332
1831
  blit(target) {
1333
1832
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
1334
- gl.viewport(0, 0, target.size, target.size);
1833
+ gl.viewport(0, 0, target.w, target.h);
1335
1834
  gl.drawArrays(gl.TRIANGLES, 0, 3);
1336
1835
  },
1337
1836
  };
@@ -1689,20 +2188,25 @@ function applyRasterPipeline(el, rasterNodes) {
1689
2188
 
1690
2189
  let ro = null;
1691
2190
  let resizeTimer = 0;
2191
+ // Re-measure the host and capture again. Shared by the
2192
+ // ResizeObserver, the window-resize fallback and the font-loading
2193
+ // recapture, so all three agree on what "resize the canvas" means.
2194
+ const remeasureAndCapture = () => {
2195
+ if (destroyed || mode !== "snapshot") return;
2196
+ const m = measure();
2197
+ rect.width = m.width;
2198
+ rect.height = m.height;
2199
+ canvas.width = Math.round(m.width * dpr);
2200
+ canvas.height = Math.round(m.height * dpr);
2201
+ canvas.style.width = m.width + "px";
2202
+ canvas.style.height = m.height + "px";
2203
+ snapshotCapture();
2204
+ };
2205
+
1692
2206
  if (typeof ResizeObserver !== "undefined") {
1693
2207
  ro = new ResizeObserver(() => {
1694
2208
  clearTimeout(resizeTimer);
1695
- resizeTimer = setTimeout(() => {
1696
- if (destroyed || mode !== "snapshot") return;
1697
- const m = measure();
1698
- rect.width = m.width;
1699
- rect.height = m.height;
1700
- canvas.width = Math.round(m.width * dpr);
1701
- canvas.height = Math.round(m.height * dpr);
1702
- canvas.style.width = m.width + "px";
1703
- canvas.style.height = m.height + "px";
1704
- snapshotCapture();
1705
- }, 150);
2209
+ resizeTimer = setTimeout(remeasureAndCapture, 150);
1706
2210
  });
1707
2211
  ro.observe(el);
1708
2212
  }
@@ -1719,17 +2223,7 @@ function applyRasterPipeline(el, rasterNodes) {
1719
2223
  clearTimeout(winResizeTimer);
1720
2224
  winResizeTimer = setTimeout(() => {
1721
2225
  if (destroyed) return;
1722
- if (mode === "snapshot") {
1723
- const m = measure();
1724
- rect.width = m.width;
1725
- rect.height = m.height;
1726
- canvas.width = Math.round(m.width * dpr);
1727
- canvas.height = Math.round(m.height * dpr);
1728
- canvas.style.width = m.width + "px";
1729
- canvas.style.height = m.height + "px";
1730
- snapshotCapture();
1731
- return;
1732
- }
2226
+ if (mode === "snapshot") { remeasureAndCapture(); return; }
1733
2227
  const w = el.clientWidth ||
1734
2228
  (el.parentElement && el.parentElement.clientWidth) || rect.width;
1735
2229
  canvas.style.width = w + "px";
@@ -1754,6 +2248,19 @@ function applyRasterPipeline(el, rasterNodes) {
1754
2248
  raf = requestAnimationFrame(draw);
1755
2249
  } else {
1756
2250
  snapshotCapture().then(() => { raf = requestAnimationFrame(draw); });
2251
+ // A web font that arrives after the first capture changes glyph
2252
+ // metrics, and therefore line breaking: the snapshot would keep
2253
+ // whatever the fallback font wrapped to while the DOM underneath
2254
+ // reflows to something else. The element box does not necessarily
2255
+ // change, so the ResizeObserver never fires and the mismatch
2256
+ // persists until an unrelated resize. Capture once more when the
2257
+ // fonts settle.
2258
+ if (typeof document !== "undefined" && document.fonts &&
2259
+ typeof document.fonts.ready === "object") {
2260
+ Promise.resolve(document.fonts.ready)
2261
+ .then(remeasureAndCapture)
2262
+ .catch(() => {});
2263
+ }
1757
2264
  }
1758
2265
 
1759
2266
  return {
@@ -1788,4 +2295,7 @@ function applyRasterPipeline(el, rasterNodes) {
1788
2295
  export {
1789
2296
  applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
1790
2297
  isHTMLInCanvasAvailable, DRIVER_NAMES,
2298
+ // Exported so which branch a `switch` takes can be asserted directly
2299
+ // rather than inferred from pixels.
2300
+ resolveSwitches,
1791
2301
  };