kuinetic 0.1.4 → 0.2.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 (124) hide show
  1. package/README.md +93 -10
  2. package/dist/esm/advanced/index.mjs +4727 -0
  3. package/dist/esm/chunk-AEQGC2KM.mjs +189 -0
  4. package/dist/esm/chunk-MQSNMYRP.mjs +9332 -0
  5. package/dist/esm/chunk-RVTYN6NZ.mjs +3446 -0
  6. package/dist/esm/chunk-U43BX5O2.mjs +2984 -0
  7. package/dist/esm/core/index.mjs +16 -9
  8. package/dist/esm/effects/index.mjs +19 -4
  9. package/dist/esm/index.mjs +17 -10
  10. package/dist/kuinetic.advanced.js +5070 -0
  11. package/dist/kuinetic.advanced.min.js +749 -0
  12. package/dist/kuinetic.all.js +15147 -6039
  13. package/dist/kuinetic.all.min.js +2 -0
  14. package/dist/kuinetic.css +2801 -318
  15. package/dist/kuinetic.js +15147 -6038
  16. package/dist/kuinetic.min.css +1 -0
  17. package/dist/kuinetic.min.js +7 -0
  18. package/dist/types/3d/angles.d.ts +30 -0
  19. package/dist/types/3d/flattening.d.ts +49 -0
  20. package/dist/types/3d/index.d.ts +36 -0
  21. package/dist/types/3d/model-3d.d.ts +115 -0
  22. package/dist/types/3d/register.d.ts +26 -0
  23. package/dist/types/3d/webgl.d.ts +24 -0
  24. package/dist/types/advanced/audio.d.ts +322 -0
  25. package/dist/types/advanced/base.d.ts +233 -0
  26. package/dist/types/advanced/camera-3d.d.ts +236 -0
  27. package/dist/types/advanced/fluid-cursor.d.ts +149 -0
  28. package/dist/types/advanced/gl-utils.d.ts +52 -0
  29. package/dist/types/advanced/glsl.d.ts +60 -0
  30. package/dist/types/advanced/index.d.ts +23 -0
  31. package/dist/types/advanced/particles.d.ts +108 -0
  32. package/dist/types/advanced/scenes.d.ts +207 -0
  33. package/dist/types/advanced/shaders.d.ts +972 -0
  34. package/dist/types/browser/boot.d.ts +72 -0
  35. package/dist/types/core/activation.d.ts +227 -2
  36. package/dist/types/core/animator.d.ts +446 -11
  37. package/dist/types/core/attrs.d.ts +9 -0
  38. package/dist/types/core/breakpoints.d.ts +260 -0
  39. package/dist/types/core/bundles.d.ts +71 -0
  40. package/dist/types/core/callback.d.ts +64 -0
  41. package/dist/types/core/capabilities.d.ts +47 -0
  42. package/dist/types/core/channels.d.ts +120 -2
  43. package/dist/types/core/cloak-selectors.d.ts +47 -0
  44. package/dist/types/core/compile.d.ts +207 -2
  45. package/dist/types/core/control.d.ts +218 -0
  46. package/dist/types/core/declarations.d.ts +128 -0
  47. package/dist/types/core/derived/aggregate.d.ts +43 -0
  48. package/dist/types/core/derived/book.d.ts +30 -0
  49. package/dist/types/core/derived/diagnostics.d.ts +51 -0
  50. package/dist/types/core/derived/group-gate.d.ts +62 -0
  51. package/dist/types/core/derived/install.d.ts +110 -0
  52. package/dist/types/core/derived/late-matches.d.ts +19 -0
  53. package/dist/types/core/derived/types.d.ts +108 -0
  54. package/dist/types/core/easing.d.ts +57 -0
  55. package/dist/types/core/element-config.d.ts +47 -6
  56. package/dist/types/core/element-size.d.ts +23 -0
  57. package/dist/types/core/event-sources.d.ts +39 -0
  58. package/dist/types/core/flip.d.ts +34 -1
  59. package/dist/types/core/gesture.d.ts +12 -0
  60. package/dist/types/core/host-facts.d.ts +155 -0
  61. package/dist/types/core/index.d.ts +7 -3
  62. package/dist/types/core/owned-styles.d.ts +129 -2
  63. package/dist/types/core/params.d.ts +48 -0
  64. package/dist/types/core/parse.d.ts +28 -1
  65. package/dist/types/core/register-into.d.ts +29 -0
  66. package/dist/types/core/registry.d.ts +10 -3
  67. package/dist/types/core/repeat.d.ts +91 -0
  68. package/dist/types/core/sequence.d.ts +126 -0
  69. package/dist/types/core/stagger-config.d.ts +199 -0
  70. package/dist/types/core/stagger-keys.d.ts +63 -0
  71. package/dist/types/core/stagger.d.ts +187 -6
  72. package/dist/types/core/style-plan.d.ts +21 -16
  73. package/dist/types/core/target.d.ts +128 -0
  74. package/dist/types/core/threshold-reachability.d.ts +45 -0
  75. package/dist/types/core/time-scale.d.ts +9 -0
  76. package/dist/types/core/toggle-actions.d.ts +141 -0
  77. package/dist/types/core/travel.d.ts +59 -0
  78. package/dist/types/core/types.d.ts +699 -13
  79. package/dist/types/core/unquoted-selectors.d.ts +53 -0
  80. package/dist/types/effects/carousel/drag.d.ts +80 -0
  81. package/dist/types/effects/carousel/index.d.ts +149 -0
  82. package/dist/types/effects/catalog/discrete.d.ts +13 -0
  83. package/dist/types/effects/catalog/interaction-proximity.d.ts +24 -0
  84. package/dist/types/effects/catalog/interaction-reveal.d.ts +248 -0
  85. package/dist/types/effects/catalog/interaction-shared.d.ts +42 -0
  86. package/dist/types/effects/catalog/interaction-states.d.ts +68 -0
  87. package/dist/types/effects/catalog/materials.d.ts +67 -0
  88. package/dist/types/effects/catalog/media-shared.d.ts +3 -2
  89. package/dist/types/effects/catalog/numbers-shared.d.ts +8 -5
  90. package/dist/types/effects/catalog/shared.d.ts +1 -1
  91. package/dist/types/effects/catalog/text-shared.d.ts +86 -10
  92. package/dist/types/effects/catalog/transforms.d.ts +13 -0
  93. package/dist/types/effects/catalog/view-transitions.d.ts +13 -0
  94. package/dist/types/effects/forms/primitives.d.ts +15 -4
  95. package/dist/types/effects/gestures/index.d.ts +53 -0
  96. package/dist/types/effects/index.d.ts +5 -0
  97. package/dist/types/effects/layout/presets.d.ts +35 -0
  98. package/dist/types/effects/motion-path/index.d.ts +35 -0
  99. package/dist/types/effects/scroll-mechanics/params.d.ts +23 -0
  100. package/dist/types/effects/scroll-mechanics/presets.d.ts +51 -0
  101. package/dist/types/effects/scroll-mechanics/section-index.d.ts +47 -0
  102. package/dist/types/effects/shared.d.ts +124 -4
  103. package/dist/types/effects/step-index.d.ts +121 -0
  104. package/dist/types/effects/step-marking.d.ts +41 -33
  105. package/dist/types/effects/svg/icon-parts.d.ts +23 -0
  106. package/dist/types/effects/three-d/flip-parts.d.ts +46 -0
  107. package/dist/types/effects/tween/index.d.ts +57 -0
  108. package/dist/types/effects/tween/properties.d.ts +107 -0
  109. package/dist/types/effects/tween/waypoints.d.ts +106 -0
  110. package/dist/types/showcase/compare.d.ts +4 -0
  111. package/dist/types/showcase/device-frame.d.ts +3 -0
  112. package/dist/types/showcase/hotspots.d.ts +3 -0
  113. package/dist/types/showcase/index.d.ts +30 -0
  114. package/dist/types/showcase/lightbox.d.ts +3 -0
  115. package/dist/types/showcase/media-source.d.ts +13 -0
  116. package/dist/types/showcase/modal-shell.d.ts +22 -0
  117. package/dist/types/showcase/scroll-story.d.ts +4 -0
  118. package/dist/types/showcase/shared.d.ts +47 -0
  119. package/dist/types/showcase/slideshow.d.ts +3 -0
  120. package/dist/types/showcase/slow-mo.d.ts +5 -0
  121. package/package.json +14 -7
  122. package/dist/esm/chunk-7WMNPIOZ.mjs +0 -4883
  123. package/dist/esm/chunk-JT4PZL3A.mjs +0 -773
  124. package/dist/esm/chunk-TJDDIQRG.mjs +0 -1290
@@ -0,0 +1,4727 @@
1
+ import {
2
+ createStyleClaim
3
+ } from "../chunk-AEQGC2KM.mjs";
4
+
5
+ // src/advanced/glsl.ts
6
+ var AUDIO_GAIN_GLSL = `uniform float u_audio;
7
+ float audioGain() { return 1.0 + clamp(u_audio, 0.0, 1.0); }
8
+ `;
9
+ var SHAPE_MASK_GLSL = `uniform vec4 u_maskBox;
10
+ uniform vec4 u_maskRx;
11
+ uniform vec4 u_maskRy;
12
+ uniform float u_maskAlpha;
13
+ float shapeMask() {
14
+ if (u_maskBox.z <= 0.0 || u_maskBox.w <= 0.0) return u_maskAlpha;
15
+ vec2 p = gl_FragCoord.xy - u_maskBox.xy;
16
+ vec2 h = u_maskBox.zw;
17
+ bool left = p.x < 0.0;
18
+ float rx = p.y >= 0.0 ? (left ? u_maskRx.x : u_maskRx.y) : (left ? u_maskRx.w : u_maskRx.z);
19
+ float ry = p.y >= 0.0 ? (left ? u_maskRy.x : u_maskRy.y) : (left ? u_maskRy.w : u_maskRy.z);
20
+ rx = min(rx, h.x);
21
+ ry = min(ry, h.y);
22
+ if (rx <= 0.0 || ry <= 0.0) return u_maskAlpha;
23
+ vec2 q = abs(p) - (h - vec2(rx, ry));
24
+ if (q.x <= 0.0 || q.y <= 0.0) return u_maskAlpha;
25
+ float d = (length(q / vec2(rx, ry)) - 1.0) * min(rx, ry);
26
+ return u_maskAlpha * clamp(0.5 - d, 0.0, 1.0);
27
+ }
28
+ `;
29
+ var NOISE_GLSL = `const int KUI_MAX_OCTAVES = 6;
30
+
31
+ /**
32
+ * Integer avalanche over one lattice coordinate.
33
+ *
34
+ * Deliberately not the \`fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453)\` idiom. That relies
35
+ * on the low bits of a large sine, which \`highp\` does not reliably have: on several mobile GPUs
36
+ * neighbouring cells stay correlated and the field bands visibly. GLSL ES 3.00 has real unsigned
37
+ * integer arithmetic, so a proper mix is both better distributed and cheaper.
38
+ */
39
+ uint kuiHash(uvec3 v) {
40
+ uint h = v.x * 0x8da6b343u ^ v.y * 0xd8163841u ^ v.z * 0xcb1ab31fu;
41
+ h ^= h >> 15u; h *= 0x2c1b3c6du;
42
+ h ^= h >> 12u; h *= 0x297a2d39u;
43
+ h ^= h >> 15u;
44
+ return h;
45
+ }
46
+
47
+ /**
48
+ * A unit gradient vector for one lattice corner.
49
+ *
50
+ * The three components read *non-overlapping* 10-bit fields. Overlapping slices of one hash are
51
+ * correlated with each other, which shows up as gradients clustering around a diagonal and the
52
+ * noise developing a directional grain. The \`1e-5\` keeps \`normalize\` defined for the one input
53
+ * that hashes to exactly zero.
54
+ */
55
+ vec3 kuiGradient(uvec3 cell) {
56
+ uint h = kuiHash(cell);
57
+ vec3 g = vec3(
58
+ float((h ) & 1023u),
59
+ float((h >> 10u ) & 1023u),
60
+ float((h >> 20u ) & 1023u)
61
+ ) * (2.0 / 1023.0) - 1.0;
62
+ return normalize(g + vec3(1e-5));
63
+ }
64
+
65
+ /**
66
+ * One octave of gradient noise, approximately -1..1.
67
+ *
68
+ * The lattice index is biased into unsigned space before hashing: seeds, scroll and a centred UV
69
+ * field all reach negative coordinates, and \`uvec3\` of a negative \`ivec3\` wraps \u2014 which is
70
+ * harmless for a hash, but only once every negative input maps somewhere distinct rather than
71
+ * folding onto its positive twin.
72
+ */
73
+ float kuiNoise(vec3 p) {
74
+ vec3 i = floor(p);
75
+ vec3 f = p - i;
76
+ vec3 w = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
77
+ uvec3 c = uvec3(ivec3(i) + 0x10000);
78
+ float n000 = dot(kuiGradient(c + uvec3(0u, 0u, 0u)), f - vec3(0.0, 0.0, 0.0));
79
+ float n100 = dot(kuiGradient(c + uvec3(1u, 0u, 0u)), f - vec3(1.0, 0.0, 0.0));
80
+ float n010 = dot(kuiGradient(c + uvec3(0u, 1u, 0u)), f - vec3(0.0, 1.0, 0.0));
81
+ float n110 = dot(kuiGradient(c + uvec3(1u, 1u, 0u)), f - vec3(1.0, 1.0, 0.0));
82
+ float n001 = dot(kuiGradient(c + uvec3(0u, 0u, 1u)), f - vec3(0.0, 0.0, 1.0));
83
+ float n101 = dot(kuiGradient(c + uvec3(1u, 0u, 1u)), f - vec3(1.0, 0.0, 1.0));
84
+ float n011 = dot(kuiGradient(c + uvec3(0u, 1u, 1u)), f - vec3(0.0, 1.0, 1.0));
85
+ float n111 = dot(kuiGradient(c + uvec3(1u, 1u, 1u)), f - vec3(1.0, 1.0, 1.0));
86
+ float nx00 = mix(n000, n100, w.x);
87
+ float nx10 = mix(n010, n110, w.x);
88
+ float nx01 = mix(n001, n101, w.x);
89
+ float nx11 = mix(n011, n111, w.x);
90
+ // 2/sqrt(3) \u2014 a 3D gradient-noise octave peaks near sqrt(3)/2, so this brings it to about -1..1.
91
+ return mix(mix(nx00, nx10, w.y), mix(nx01, nx11, w.y), w.z) * 1.1547;
92
+ }
93
+
94
+ /**
95
+ * Octaves summed and **normalised by the amplitude sum**, approximately -1..1 at every octave count.
96
+ *
97
+ * The division is the whole point of the function. Without it, raising the octave count raises the
98
+ * total amplitude, so the one authored knob that means "how much fine structure" would silently be
99
+ * a contrast control as well \u2014 two behaviours behind one parameter, which is the class of bug this
100
+ * repository keeps rediscovering. Gain and lacunarity stay fixed at 0.5 and 2.0: they are the two
101
+ * numbers that make layered noise look wrong in a hundred ways and right in one.
102
+ *
103
+ * The loop bound is a constant with an early break because a loop bound that is itself a uniform
104
+ * forces some drivers to unroll against the worst case anyway, and this way the cost of a low
105
+ * \`detail\` is genuinely low.
106
+ */
107
+ float kuiFbm(vec3 p, int octaves) {
108
+ float amp = 1.0;
109
+ float freq = 1.0;
110
+ float sum = 0.0;
111
+ float norm = 0.0;
112
+ for (int i = 0; i < KUI_MAX_OCTAVES; i++) {
113
+ if (i >= octaves) break;
114
+ sum += amp * kuiNoise(p * freq);
115
+ norm += amp;
116
+ amp *= 0.5;
117
+ freq *= 2.0;
118
+ }
119
+ return sum / max(norm, 1e-5);
120
+ }
121
+ `;
122
+ var FILTER_NOISE_GLSL = `uniform float u_noise;
123
+ uniform float u_seed;
124
+ uniform float u_scale;
125
+ uniform float u_warp;
126
+ uniform int u_detail;
127
+
128
+ /**
129
+ * A sine's RMS is 0.707. Amplitude-normalised fbm's is about 0.23 \u2014 it reaches its \`1.0\` peak
130
+ * rarely, which is what makes it look like noise rather than a wave. Handing the programs the raw
131
+ * field would therefore read as the *effect being turned down*, not as it being re-textured, and
132
+ * the first thing anyone trying \`noise: 1\` would do is reach for \`strength\`. This brings the two
133
+ * to the same typical magnitude so the amount is a change of texture and nothing else.
134
+ */
135
+ const float KUI_FBM_SINE_RMS = 3.0;
136
+
137
+ /**
138
+ * One scalar of animated noise, scaled to stand in for a unit sine at a stated rate.
139
+ *
140
+ * \`cycles\` and \`rate\` are the spatial and temporal frequency of the term being replaced, in
141
+ * cycles rather than radians \u2014 each call site divides its own trig coefficients by 2*pi and passes
142
+ * the result, so \`noise: 1\` lands on the same feature size and the same speed as the wave it took
143
+ * over from. That is the entire mechanism keeping this from being a new look.
144
+ *
145
+ * \`phase\` decorrelates sibling calls: two components of one flow vector must not be the same
146
+ * field, and offsetting the seed is cheaper than hashing a second one.
147
+ *
148
+ * \`u_warp\` costs two further fbm evaluations and is skipped entirely when it is zero.
149
+ */
150
+ float kuiFilterField(vec2 uv, float t, float cycles, float rate, float phase) {
151
+ vec3 s = vec3(u_seed * 137.31, u_seed * 71.17, u_seed * 29.73) + phase;
152
+ vec3 q = vec3((uv - 0.5) * cycles * max(u_scale, 0.05), t * rate) + s;
153
+ if (u_warp > 0.0) {
154
+ float wx = kuiFbm(q * 0.5 + vec3(5.2, 1.3, 7.1), u_detail);
155
+ float wy = kuiFbm(q * 0.5 + vec3(19.7, 11.1, 3.7), u_detail);
156
+ q.xy += vec2(wx, wy) * u_warp;
157
+ }
158
+ return kuiFbm(q, u_detail) * KUI_FBM_SINE_RMS;
159
+ }
160
+ `;
161
+ var FULLSCREEN_QUAD_VS = `#version 300 es
162
+ in vec2 a_position;
163
+ uniform vec2 u_uvOrigin;
164
+ uniform vec2 u_uvScale;
165
+ out vec2 v_uv;
166
+ void main() {
167
+ vec2 uv = (a_position + 1.0) * 0.5;
168
+ uv.y = 1.0 - uv.y;
169
+ v_uv = u_uvOrigin + uv * u_uvScale;
170
+ gl_Position = vec4(a_position, 0.0, 1.0);
171
+ }
172
+ `;
173
+ var DISPLACE_FS = `#version 300 es
174
+ precision highp float;
175
+ in vec2 v_uv;
176
+ out vec4 fragColor;
177
+ uniform sampler2D u_image;
178
+ uniform float u_time;
179
+ uniform float u_strength;
180
+ uniform float u_frequency;
181
+ uniform float u_chromatic;
182
+ uniform vec2 u_mouse;
183
+ uniform vec4 u_tint;
184
+ uniform int u_blend;
185
+ uniform float u_duotone;
186
+ uniform vec4 u_color1;
187
+ uniform vec4 u_color2;
188
+ uniform float u_progress;
189
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}
190
+ vec4 applyBlend(vec4 base, vec4 tint, int mode) {
191
+ // \`base\` is premultiplied \u2014 \`createGLTexture\` uploads with \`UNPACK_PREMULTIPLY_ALPHA_WEBGL\` to
192
+ // match a \`premultipliedAlpha: true\` context \u2014 while \`tint\` is straight rgba out of
193
+ // \`parseColor\`. All four blends are defined on *straight* colour, so every mode takes the same
194
+ // path: un-premultiply, blend, and re-premultiply by this pixel's own coverage.
195
+ //
196
+ // Screen and add were the obvious cases. Run over all four premultiplied components,
197
+ // \`screen((0,0,0,0), (1,0,0,1))\` is \`(1,0,0,1)\`, an opaque red pixel where the source had no
198
+ // coverage at all \u2014 \`blend: screen tint: red\` filled a transparent PNG's surround with a solid
199
+ // red rectangle. Normal and multiply *looked* safe as a plain \`base * tint\`, because a transparent
200
+ // base does multiply to transparent whatever the tint is. It is the tint's alpha they got wrong:
201
+ // \`(1,1,1,1) * (1,0,0,0)\` is \`(1,0,0,0)\`, red at zero coverage, and a premultiplied canvas
202
+ // composites \`rgb > a\` *additively* \u2014 \`tint: rgba(255,0,0,0)\`, a fully transparent tint, turned
203
+ // an opaque image red, and any translucent tint bled the same way in proportion.
204
+ //
205
+ // The \`min\` on \`s\` is for \`main()\`'s chromatic sampling, which reads the three colour channels
206
+ // at three offsets and \`a\` at a fourth: at a transparent PNG's edge that can pair \`rgb\` from
207
+ // opaque neighbours with \`a\` from a transparent centre, an input that already breaks the
208
+ // invariant. Clamping the un-premultiplied colour to 1 is what keeps the output inside it.
209
+ //
210
+ // Alpha is \`base.a * tint.a\` \u2014 the source's coverage, faded by a translucent tint. Whatever the
211
+ // mode, the result satisfies \`rgb <= a\`, which is the whole contract of a premultiplied pixel.
212
+ float a = base.a * tint.a;
213
+ vec3 s = base.a > 0.0 ? min(base.rgb / base.a, vec3(1.0)) : vec3(0.0);
214
+ vec3 c = mode == 1 ? 1.0 - (1.0 - s) * (1.0 - tint.rgb)
215
+ : mode == 3 ? min(s + tint.rgb, vec3(1.0))
216
+ : s * tint.rgb;
217
+ return vec4(c * a, a);
218
+ }
219
+
220
+ void main() {
221
+ vec2 m = u_mouse;
222
+ float d = distance(v_uv, m);
223
+ float ripple = sin(d * u_frequency - u_time * 3.0) * exp(-d * 4.0);
224
+ float pFactor = u_progress >= 0.0 ? u_progress : 1.0;
225
+ // Audio widens the ripple's throw (up to 2x), leaving its shape and speed alone.
226
+ vec2 disp = normalize(v_uv - m + 0.0001) * ripple * u_strength * 0.05 * pFactor * audioGain();
227
+
228
+ float cr = texture(u_image, v_uv + disp * (1.0 + u_chromatic)).r;
229
+ float cg = texture(u_image, v_uv + disp).g;
230
+ float cb = texture(u_image, v_uv + disp * (1.0 - u_chromatic)).b;
231
+ float ca = texture(u_image, v_uv + disp).a;
232
+ vec4 color = vec4(cr, cg, cb, ca);
233
+
234
+ if (u_duotone > 0.5) {
235
+ float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114));
236
+ // \`* color.a\` because the ramp's two colours are straight rgba and everything downstream of
237
+ // here is premultiplied. Assigning one of them to a transparent pixel's \`rgb\` left \`rgb > 0\`
238
+ // with \`a == 0\`, which a \`premultipliedAlpha: true\` canvas composites *additively* \u2014 a
239
+ // transparent PNG's surround glowing in \`color1\` over whatever the page put behind it.
240
+ //
241
+ // \`lum\` is still read off the premultiplied colour, which is deliberate and unchanged: it
242
+ // makes a half-covered pixel map nearer \`color1\`, matching how the alpha stencil in
243
+ // \`GENERATIVE_FS\` reads the same kind of edge.
244
+ color.rgb = mix(u_color1.rgb, u_color2.rgb, lum) * color.a;
245
+ }
246
+
247
+ fragColor = applyBlend(color, u_tint, u_blend) * shapeMask();
248
+ }
249
+ `;
250
+ var FLUID_FS = `#version 300 es
251
+ precision highp float;
252
+ in vec2 v_uv;
253
+ out vec4 fragColor;
254
+ uniform sampler2D u_image;
255
+ uniform float u_time;
256
+ uniform float u_strength;
257
+ uniform vec2 u_mouse;
258
+ uniform vec4 u_tint;
259
+ uniform float u_progress;
260
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}${NOISE_GLSL}${FILTER_NOISE_GLSL}
261
+ void main() {
262
+ vec2 uv = v_uv;
263
+ vec2 m = u_mouse;
264
+ float d = distance(uv, m);
265
+ // Audio scales both halves of the flow \u2014 the ambient drift and the pointer's push \u2014 so a beat
266
+ // reads as the whole surface moving faster, not just around the cursor.
267
+ float gain = audioGain();
268
+ float force = exp(-d * 6.0) * u_strength * gain;
269
+ // The ambient drift. Two perpendicular waves at 10 rad across the box and 1 rad/s, which is
270
+ // 10/2pi = 1.5915 cycles and 1/2pi = 0.1592 cycles a second \u2014 the numbers kuiFilterField needs
271
+ // to put its field at the same size and speed. The drift is the whole of the ambient motion
272
+ // here, so this is the mode where the noise core reads most strongly: a regular cross-hatched
273
+ // sway becomes an irregular current.
274
+ vec2 wave = vec2(sin(u_time + uv.y * 10.0), cos(u_time + uv.x * 10.0));
275
+ if (u_noise > 0.0) {
276
+ wave = mix(wave, vec2(
277
+ kuiFilterField(uv, u_time, 1.5915, 0.1592, 0.0),
278
+ kuiFilterField(uv, u_time, 1.5915, 0.1592, 31.4)
279
+ ), u_noise);
280
+ }
281
+ vec2 flow = wave * 0.02 * gain;
282
+ float pFactor = u_progress >= 0.0 ? u_progress : 1.0;
283
+ vec2 offset = (flow + (uv - m) * force * 0.1) * pFactor;
284
+ fragColor = texture(u_image, uv + offset) * u_tint * shapeMask();
285
+ }
286
+ `;
287
+ var LIQUID_FS = `#version 300 es
288
+ precision highp float;
289
+ in vec2 v_uv;
290
+ out vec4 fragColor;
291
+ uniform sampler2D u_image;
292
+ uniform float u_time;
293
+ uniform float u_strength;
294
+ uniform vec4 u_tint;
295
+ uniform float u_progress;
296
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}${NOISE_GLSL}${FILTER_NOISE_GLSL}
297
+ void main() {
298
+ vec2 uv = v_uv;
299
+ // 12 rad and 2 rad/s is 1.9099 cycles at 0.3183 a second; 10 rad and 1.5 rad/s is 1.5915 at
300
+ // 0.2387. The two waves keep their own rates under noise rather than being merged into one
301
+ // field, because the difference between them is what stops the surface reading as a single
302
+ // sheet sliding.
303
+ float w1 = sin(uv.y * 12.0 + u_time * 2.0);
304
+ float w2 = cos(uv.x * 10.0 - u_time * 1.5);
305
+ if (u_noise > 0.0) {
306
+ w1 = mix(w1, kuiFilterField(uv, u_time, 1.9099, 0.3183, 0.0), u_noise);
307
+ w2 = mix(w2, kuiFilterField(uv, u_time, 1.5915, 0.2387, 47.3), u_noise);
308
+ }
309
+ w1 *= 0.015;
310
+ w2 *= 0.015;
311
+ float pFactor = u_progress >= 0.0 ? u_progress : 1.0;
312
+ // Audio deepens the wave (up to 2x) without changing its wavelength or speed.
313
+ vec2 offset = vec2(w1, w2) * u_strength * pFactor * audioGain();
314
+ fragColor = texture(u_image, uv + offset) * u_tint * shapeMask();
315
+ }
316
+ `;
317
+ var PARTICLES_FS = `#version 300 es
318
+ precision highp float;
319
+ in vec2 v_uv;
320
+ out vec4 fragColor;
321
+ uniform sampler2D u_image;
322
+ uniform float u_time;
323
+ uniform float u_strength;
324
+ uniform vec4 u_tint;
325
+ uniform float u_progress;
326
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}${NOISE_GLSL}${FILTER_NOISE_GLSL}
327
+ // No u_mouse here, deliberately. This program declared one from the day it was written and never
328
+ // read it, so particles has never responded to the pointer \u2014 the declaration was a promise the
329
+ // body did not keep, and the compiler dropped it anyway (an unused uniform gets no location, so
330
+ // uploadCoordUniforms was already skipping it and removing it changes no pixel). It is deleted
331
+ // rather than wired because wiring it would restyle a mode that is already in use without anyone
332
+ // asking, which is the same argument FILTER_NOISE_GLSL's doc makes for noise: being opt-in. The
333
+ // pointer gesture that *was* asked for lives on the generative program below, under hover:.
334
+ void main() {
335
+ vec2 uv = v_uv;
336
+ vec2 grid = fract(uv * 40.0) - 0.5;
337
+ float dist = length(grid);
338
+ // 100 rad across the box and 5 rad/s is 15.915 cycles at 0.7958 a second. That is by far the
339
+ // highest frequency of the four, and deliberately so: the trig term here is a diagonal travelling
340
+ // stripe, which is the most visible of the four repeats. Under noise the stripe becomes a
341
+ // scintillation, which is the largest change of the set and is the reason the parameter exists
342
+ // rather than the swap being unconditional.
343
+ //
344
+ // The clamp is not damage control. Mapped through KUI_FBM_SINE_RMS the field reaches 0 and 1
345
+ // often, which is what a sine does too \u2014 sin()*0.5+0.5 is arcsine-distributed and spends most of
346
+ // its time at the extremes. Clamping reproduces that; a soft field would give a duller sparkle
347
+ // than the one being replaced.
348
+ //
349
+ // Cost: this is one fbm per pixel, three with warp on, over the whole quad. At detail 6 the
350
+ // finest octave here sits near 509 cycles across the box, which is sub-pixel on a small element
351
+ // and will alias \u2014 high detail belongs on the low-frequency modes.
352
+ float sparkle = sin(u_time * 5.0 + dot(uv, vec2(100.0))) * 0.5 + 0.5;
353
+ if (u_noise > 0.0) {
354
+ float n = clamp(kuiFilterField(uv, u_time, 15.915, 0.7958, 0.0) * 0.5 + 0.5, 0.0, 1.0);
355
+ sparkle = mix(sparkle, n, u_noise);
356
+ }
357
+ vec4 tex = texture(u_image, uv);
358
+ float pFactor = u_progress >= 0.0 ? u_progress : 1.0;
359
+ float dotMask = smoothstep(0.4, 0.2, dist) * u_strength * pFactor;
360
+ // Audio brightens the sparkle (0.3 -> at most 0.6) rather than growing the dots: the mask is
361
+ // already multiplied by an unbounded u_strength and feeds a mix(), so widening it would
362
+ // extrapolate past the texture over a larger and larger area.
363
+ fragColor = mix(tex, tex * u_tint + sparkle * 0.3 * audioGain(), dotMask) * shapeMask();
364
+ }
365
+ `;
366
+ var MORPH_FS = `#version 300 es
367
+ precision highp float;
368
+ in vec2 v_uv;
369
+ out vec4 fragColor;
370
+ uniform sampler2D u_image;
371
+ uniform sampler2D u_image_to;
372
+ uniform float u_time;
373
+ uniform float u_strength;
374
+ uniform float u_progress;
375
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}${NOISE_GLSL}${FILTER_NOISE_GLSL}
376
+ void main() {
377
+ vec2 uv = v_uv;
378
+ float progress = u_progress >= 0.0 ? clamp(u_progress, 0.0, 1.0) : clamp(u_strength, 0.0, 1.0);
379
+ // Audio wobbles the crossfade harder. Deliberately not applied to u_progress: the morph's
380
+ // position between the two images belongs to the author (or to scroll), and letting a beat
381
+ // drive it would make the transition jump backwards on every quiet frame.
382
+ //
383
+ // 20 rad and 1 rad/s is 3.1831 cycles at 0.1592 a second. The 0.7071 is the one place the shared
384
+ // RMS gain is wrong by construction: the term being replaced is a *product* of two unit sines,
385
+ // whose RMS is 0.5 rather than a single sine's 0.7071, so matching it means taking the field
386
+ // down by that ratio. Without it the displacement under noise would be about 40% deeper than
387
+ // the one it stands in for \u2014 a visibly stronger morph, not the same one better textured.
388
+ float noise = sin(uv.x * 20.0 + u_time) * cos(uv.y * 20.0 + u_time);
389
+ if (u_noise > 0.0) {
390
+ noise = mix(noise, kuiFilterField(uv, u_time, 3.1831, 0.1592, 0.0) * 0.7071, u_noise);
391
+ }
392
+ noise = noise * 0.05 * audioGain();
393
+ vec4 c1 = texture(u_image, uv + vec2(noise * (1.0 - progress)));
394
+ vec4 c2 = texture(u_image_to, uv - vec2(noise * progress));
395
+ fragColor = mix(c1, c2, smoothstep(0.2, 0.8, progress)) * shapeMask();
396
+ }
397
+ `;
398
+ var GRADIENT_FS = `#version 300 es
399
+ precision highp float;
400
+ in vec2 v_uv;
401
+ out vec4 fragColor;
402
+ uniform float u_time;
403
+ uniform float u_strength;
404
+ uniform float u_frequency;
405
+ uniform float u_chromatic;
406
+ uniform float u_iridescence;
407
+ uniform float u_progress;
408
+ uniform float u_seed;
409
+ uniform float u_scale;
410
+ uniform float u_warp;
411
+ uniform float u_grain;
412
+ uniform float u_hue;
413
+ uniform int u_detail;
414
+ uniform int u_bands;
415
+ uniform int u_colorCount;
416
+ uniform vec4 u_colors[5];
417
+ uniform vec4 u_tint;
418
+ uniform sampler2D u_image;
419
+ uniform int u_maskMode;
420
+ uniform vec2 u_mouse;
421
+ uniform int u_hover;
422
+ uniform vec4 u_backdrop;
423
+ uniform float u_angle;
424
+ uniform int u_motion;
425
+ ${AUDIO_GAIN_GLSL}${SHAPE_MASK_GLSL}${NOISE_GLSL}
426
+
427
+ /** \`hover:\` as bits, so \`both\` is \`stir | light\` rather than a third code path. */
428
+ const int KUI_HOVER_STIR = 1;
429
+ const int KUI_HOVER_LIGHT = 2;
430
+
431
+ /** \`motion:\` \u2014 \`0\` is \`evolve\`, which is this program's original behaviour and its default. */
432
+ const int KUI_MOTION_DRIFT = 1;
433
+ const int KUI_MOTION_SWIRL = 2;
434
+
435
+ /**
436
+ * Rotate a point of the field's domain.
437
+ *
438
+ * \`v_uv\` is y-down, so this matrix \u2014 the ordinary counter-clockwise one \u2014 turns the *picture*
439
+ * clockwise, which is \`transform: rotate()\`'s direction and therefore the one an author already
440
+ * has an intuition for. Every call site passes \`-angle\`: rotating the sample point one way is
441
+ * what rotates what you see the other.
442
+ */
443
+ vec2 kuiRotate(vec2 v, float a) {
444
+ float c = cos(a);
445
+ float s = sin(a);
446
+ return vec2(v.x * c - v.y * s, v.x * s + v.y * c);
447
+ }
448
+
449
+ /**
450
+ * The pair of factors that carry a centred UV offset into a space that is isotropic **on screen**,
451
+ * and back out again by dividing.
452
+ *
453
+ * \`v_uv\` is normalised per axis, so one unit of \`v_uv.x\` is the box's whole width and one unit of
454
+ * \`v_uv.y\` is its whole height \u2014 two different numbers of screen pixels as soon as the box is not
455
+ * square. Anything round or rigid has to leave that space first. Scaling by \`(k, 1/k)\` for
456
+ * \`k = sqrt(halfWidth / halfHeight)\` puts both axes in units of \`sqrt(width * height)\`: a circle
457
+ * stays a circle, a rotation stays a rotation, and the unit tracks the box's overall size rather
458
+ * than one of its two dimensions. On a square box the pair is exactly \`(1, 1)\` and every user of
459
+ * it degenerates to the plain UV arithmetic it had before.
460
+ *
461
+ * It is a *ratio* of the two half-extents, so it is DPR-invariant \u2014 device pixel ratio scales
462
+ * \`u_maskBox.z\` and \`u_maskBox.w\` together and cancels.
463
+ *
464
+ * A zero half-extent is \`shapeMask()\`'s "no box measured" signal and is read the same way here:
465
+ * fall back to \`(1, 1)\` rather than divide by it.
466
+ */
467
+ vec2 kuiAspectAxes() {
468
+ if (u_maskBox.z > 0.0 && u_maskBox.w > 0.0) {
469
+ float k = sqrt(u_maskBox.z / u_maskBox.w);
470
+ return vec2(k, 1.0 / k);
471
+ }
472
+ return vec2(1.0);
473
+ }
474
+
475
+ /**
476
+ * Turn a centred UV offset by \`a\`, rigidly **on screen** rather than in the field's own stretched
477
+ * space.
478
+ *
479
+ * \`A^-1 \xB7 R(a) \xB7 A\`, with \`A = diag(kuiAspectAxes())\`: leave the per-axis-normalised space, turn
480
+ * there, come back. Rotating without the conjugation turns *and shears* \u2014 on a 160x100 box an
481
+ * authored \`angle: 45deg\` actually travels at 32 degrees, an error that grows with how far from
482
+ * square the box is. \`angle\` is documented as \`transform: rotate()\`'s convention, and this is what
483
+ * makes that true of a wide box and not only of a square one.
484
+ *
485
+ * **Not bit-identical at \`a == 0\`**, and that matters: \`A^-1 \xB7 I \xB7 A\` is \`diag(k * (1/k), (1/k) * k)\`,
486
+ * which in IEEE 754 is about an ulp away from the identity per axis rather than exactly it. Every
487
+ * caller must therefore keep its existing exact-zero guard \u2014 the defaults are unchanged because
488
+ * nothing here executes at them, not because this collapses.
489
+ *
490
+ * \`scale\` is a scalar and commutes with all three matrices, so a zoomed offset can be passed
491
+ * straight in: \`A^-1 R A (zoom * d)\` is \`zoom * (A^-1 R A d)\`.
492
+ */
493
+ vec2 kuiRotateScreen(vec2 v, float a) {
494
+ vec2 s = kuiAspectAxes();
495
+ return kuiRotate(v * s, a) / s;
496
+ }
497
+
498
+ /**
499
+ * How strongly the pointer reaches this fragment: 1 under it, falling to nothing at the edge of a
500
+ * **round** pool.
501
+ *
502
+ * Round is the whole difficulty. \`v_uv\` and \`u_mouse\` are both normalised per axis, so the
503
+ * obvious \`distance(v_uv, u_mouse)\` draws an *ellipse* as soon as the box is not square \u2014 and the
504
+ * boxes this mode is documented for are full-bleed bands, card faces and footer strips, i.e.
505
+ * precisely the wide ones. \`kuiAspectAxes()\` is the correction, and measuring the offset there
506
+ * makes the pool a circle on screen whose size tracks the box's overall scale rather than one of
507
+ * its two dimensions. On a square box the pair is exactly \`(1, 1)\` and this degenerates to the
508
+ * plain UV distance \`displace\` already uses.
509
+ *
510
+ * Gaussian rather than \`smoothstep\`, because a pool of light has no edge to see.
511
+ */
512
+ float kuiHoverFalloff() {
513
+ vec2 d = (v_uv - u_mouse) * kuiAspectAxes();
514
+ return exp(-dot(d, d) * 6.0);
515
+ }
516
+
517
+ /**
518
+ * One point of the element's UV square, in the field's own frame.
519
+ *
520
+ * A rigid motion \u2014 one rotation and one translation \u2014 so that carrying the pointer through the
521
+ * same call puts it at the same place relative to the field that it occupies on screen. That is
522
+ * what lets \`stir\` swirl the field about a centre the cursor is actually sitting on, at any
523
+ * \`angle\`, mid-\`drift\`, mid-\`swirl\`.
524
+ *
525
+ * The turn is \`kuiRotateScreen\`, so it is rigid **on screen** on a box of any shape: \`angle: 45deg\`
526
+ * points at 45 degrees on a 160x100 band, and \`motion: drift\` travels along exactly the axis the
527
+ * author named instead of a sheared one.
528
+ *
529
+ * Inert at the defaults by construction: \`orient\` of zero skips the rotation entirely and
530
+ * \`travel\` of zero subtracts an exact zero, so \`motion: evolve\` at \`angle: 0deg\` computes the
531
+ * same \`(v_uv - 0.5) * scale\` this program has always computed, bit for bit. **The guard is the
532
+ * whole of that promise** \u2014 \`kuiRotateScreen\` does not collapse to the identity at zero, it only
533
+ * gets within an ulp, so deleting the guard because "a rotation is cheap" would silently move the
534
+ * last bit of every generative pixel already on the web.
535
+ */
536
+ vec2 kuiFieldPoint(vec2 uv, float zoom, float orient, float travel) {
537
+ vec2 v = (uv - 0.5) * zoom;
538
+ if (orient != 0.0) v = kuiRotateScreen(v, -orient);
539
+ v.x -= travel;
540
+ return v;
541
+ }
542
+
543
+ /**
544
+ * \`hover: stir\` \u2014 one vortex of the sample point about the pointer.
545
+ *
546
+ * A turn that dies away from the cursor, plus a push of the *sample point* outward, which is the
547
+ * field spiralling inward and is the whole difference between a rotating disc and a stir.
548
+ * \`rel / (length(rel) + eps)\` rather than \`normalize\`, so the push fades to nothing at the centre
549
+ * instead of kicking the one pixel under the cursor in whatever direction the epsilon points.
550
+ *
551
+ * Both halves run inside \`kuiAspectAxes()\`, for the same reason \`kuiHoverFalloff\` does: the
552
+ * falloff that drives this is already a circle on screen, so a vortex measured in raw UV would be
553
+ * a round pool turning an elliptical gesture \u2014 two different ideas of "the same distance from the
554
+ * cursor" in one effect.
555
+ */
556
+ vec2 kuiStirPoint(vec2 pt, vec2 centre, float zoom, float fall) {
557
+ vec2 s = kuiAspectAxes();
558
+ vec2 rel = (pt - centre) * s;
559
+ vec2 turned = kuiRotate(rel, 1.6 * fall) + rel / (length(rel) + 1e-4) * (0.10 * zoom * fall);
560
+ return centre + turned / s;
561
+ }
562
+
563
+ /**
564
+ * The host image read as a stencil: 1 where the mark is, 0 where the page should show through.
565
+ *
566
+ * Three ways to ask, because a logo file answers the question differently depending on what it is:
567
+ *
568
+ * - \`0\` \u2014 no stencil. \`mode: gradient\`; \`u_image\` is not sampled at all.
569
+ * - \`1\` (\`mask: alpha\`) \u2014 the image's own alpha. An SVG or PNG mark on a transparent ground,
570
+ * which is what a logo asset usually is. The texture is uploaded premultiplied, so \`a\` is the
571
+ * coverage and multiplying all four channels by it stays premultiplied-correct.
572
+ * - \`2\`/\`3\` (\`mask: luma\` / \`luma-invert\`) \u2014 luminance, for a format with no alpha to read. A
573
+ * JPEG has none, so a black-on-white mark under \`alpha\` would give an all-opaque stencil and
574
+ * fill the whole rectangle with field. \`luma\` takes the *bright* pixels as the mark (white on
575
+ * black); \`luma-invert\` takes the dark ones (black on white, which is the common logo file).
576
+ * Both directions exist because guessing one silently produces a perfect negative of the mark \u2014
577
+ * an image that looks deliberate and is exactly wrong.
578
+ *
579
+ * Luminance is taken from the premultiplied colour, so a transparent pixel is black. Under
580
+ * \`luma\` that reads as "not the mark", which is the sensible answer for a file that has alpha and
581
+ * was asked for a luma stencil anyway. Under \`luma-invert\` the same pixel reads as *solid* mark \u2014
582
+ * correct for the opaque JPEG the keyword exists for, and wrong for anything with a transparent
583
+ * surround, which should be using \`alpha\`.
584
+ */
585
+ float glyphMask() {
586
+ if (u_maskMode == 0) return 1.0;
587
+ vec4 src = texture(u_image, v_uv);
588
+ if (u_maskMode == 1) return src.a;
589
+ float lum = dot(src.rgb, vec3(0.299, 0.587, 0.114));
590
+ return u_maskMode == 2 ? lum : 1.0 - lum;
591
+ }
592
+
593
+ /**
594
+ * The palette ramp, interpolated in linear-light rather than straight sRGB.
595
+ *
596
+ * \`mix()\` between two sRGB triples passes through a desaturated middle \u2014 the classic muddy
597
+ * midpoint of a naive gradient, worst exactly where two saturated brand colours meet. Squaring
598
+ * into approximately linear light, mixing there, and taking the square root back is a two-operation
599
+ * approximation of a gamma-2.0 space that removes almost all of it.
600
+ *
601
+ * \`smoothstep\` on the segment fraction, not a straight lerp, so the ramp has no visible crease at
602
+ * each stop \u2014 a crease that banding (\`u_bands\`) would otherwise land on and amplify.
603
+ */
604
+ vec3 kuiPalette(float t) {
605
+ int n = max(u_colorCount, 2);
606
+ float x = clamp(t, 0.0, 1.0) * float(n - 1);
607
+ int i = int(floor(x));
608
+ int j = min(i + 1, n - 1);
609
+ float f = smoothstep(0.0, 1.0, x - float(i));
610
+ vec3 a = u_colors[i].rgb;
611
+ vec3 b = u_colors[j].rgb;
612
+ return sqrt(mix(a * a, b * b, f));
613
+ }
614
+
615
+ /** Rotate a colour about the grey axis \u2014 Rodrigues' formula, which needs no colour-space change. */
616
+ vec3 kuiHueRotate(vec3 c, float angle) {
617
+ const vec3 axis = vec3(0.57735026);
618
+ float s = sin(angle);
619
+ float k = cos(angle);
620
+ return c * k + cross(axis, c) * s + axis * dot(axis, c) * (1.0 - k);
621
+ }
622
+
623
+ /** Per-pixel film grain. Quantised in time so it flickers at ~60 steps a second rather than per frame. */
624
+ float kuiGrain(vec2 fragCoord, float t) {
625
+ uint h = kuiHash(uvec3(uvec2(fragCoord), uint(int(t * 60.0) + 0x10000)));
626
+ return float(h & 0xffffu) / 65535.0 - 0.5;
627
+ }
628
+
629
+ void main() {
630
+ // Centred and aspect-free: the field is authored in the element's own UV space, so the same
631
+ // parameters give the same picture whatever size the box is.
632
+ float zoom = max(u_scale, 0.05);
633
+ vec3 seed = vec3(u_seed * 137.31, u_seed * 71.17, u_seed * 29.73);
634
+ float t = u_time;
635
+ float pFactor = u_progress >= 0.0 ? u_progress : 1.0;
636
+
637
+ // Orientation and travel, as one rigid motion of the domain. \`angle\` names the field's axis;
638
+ // \`motion: swirl\` turns that axis over time; \`motion: drift\` slides along it. Both are *added
639
+ // to* the third-axis evolution below rather than replacing it, so a drifting field is still
640
+ // changing as it goes rather than being one frozen picture towed across the box.
641
+ float spin = u_motion == KUI_MOTION_SWIRL ? t * 0.2 : 0.0;
642
+ float orient = u_angle + spin;
643
+ // Multiplied by the zoom, so \`drift\` crosses the box at the same visual rate whatever \`scale\`
644
+ // is. Without it, zooming in would slow the travel down \u2014 one knob quietly moving two things,
645
+ // which is the bug kuiFbm's \`/norm\` divisor exists to avoid on the octave count.
646
+ float travel = u_motion == KUI_MOTION_DRIFT ? t * 0.12 * zoom : 0.0;
647
+
648
+ vec2 p = kuiFieldPoint(v_uv, zoom, orient, travel);
649
+ // One evaluation, shared by both gestures, and skipped entirely when there is no pointer
650
+ // response authored.
651
+ float hoverFall = u_hover != 0 ? kuiHoverFalloff() : 0.0;
652
+
653
+ // Domain warp: a second field displaces the sample point of the first. This is the whole
654
+ // difference between concentric fbm contours and something that reads as liquid.
655
+ //
656
+ // **This displacement is deliberately NOT aspect-corrected, and it is the one thing here that
657
+ // is not.** \`vec2(wx, wy)\` is isotropic in the field's own normalised space, which means it is
658
+ // anisotropic on screen \u2014 the same flaw \`kuiRotateScreen\` exists to fix for \`angle\`, \`motion\`
659
+ // and the \`stir\` vortex. It is left alone because \`warp\` is *shipped and in use*: multiplying
660
+ // it through \`kuiAspectAxes()\` would restyle every live page that authored one, which is a
661
+ // price a correctness argument does not get to charge. The rule the rest of this program follows
662
+ // and this line does not: **orientation and the pointer vortex are screen-true; the domain warp
663
+ // is field-true.** If you are here to "finish the job", that is a behaviour change to real
664
+ // sites and it needs the owner, not a tidy-up commit.
665
+ vec2 q = p;
666
+ if (u_warp > 0.0) {
667
+ float wf = u_frequency * 0.1;
668
+ float wx = kuiFbm(vec3(p * wf, t * 0.35) + seed, u_detail);
669
+ float wy = kuiFbm(vec3(p * wf + 5.2, t * 0.35 + 1.3) + seed, u_detail);
670
+ q += vec2(wx, wy) * u_warp;
671
+ }
672
+
673
+ // \`hover: stir\` \u2014 the cursor dragged through the colour, like stirring thick paint.
674
+ //
675
+ // Applied here, last, to the finished sample point: the gesture is dragging paint that is
676
+ // already there, not re-warping the field before it exists. It also means \`stir\` works at the
677
+ // library defaults, where \`warp\` is 0 and there is no domain warp to ride on.
678
+ //
679
+ // A vortex, which is one gesture and not two \u2014 see \`kuiStirPoint\`, which also explains why both
680
+ // halves of it are measured in the same screen-isotropic space as the falloff that drives them.
681
+ //
682
+ // On \`mode: logo\` this cannot touch the mark's edges: \`glyphMask()\` samples \`u_image\` at the
683
+ // undistorted \`v_uv\` and multiplies the finished colour, so stirring \`q\` swirls the fill and
684
+ // leaves the stencil exactly where it was.
685
+ if ((u_hover & KUI_HOVER_STIR) != 0) {
686
+ vec2 pm = kuiFieldPoint(u_mouse, zoom, orient, travel);
687
+ q = kuiStirPoint(q, pm, zoom, hoverFall);
688
+ }
689
+
690
+ // Audio and progress scale the field's *amplitude*, leaving its shape and rate alone \u2014 so a
691
+ // beat swells the contrast and a scroll at 0 leaves a flat single colour rather than a jump cut.
692
+ float field = kuiFbm(vec3(q, t * 0.25) + seed, u_detail);
693
+ field *= u_strength * pFactor * audioGain();
694
+ float v = clamp(field * 0.5 + 0.5, 0.0, 1.0);
695
+
696
+ // Iridescence wraps the ramp back on itself, so one palette reads as many sheens across the box.
697
+ float wrapped = u_iridescence > 0.0 ? fract(v * u_iridescence) : v;
698
+
699
+ // Posterise. Divided by bands-1 so the top step reaches the end of the ramp; a plain /bands
700
+ // would stop one step short and quietly desaturate the brightest colour.
701
+ if (u_bands > 0) {
702
+ float steps = float(u_bands);
703
+ wrapped = floor(wrapped * steps) / max(steps - 1.0, 1.0);
704
+ }
705
+
706
+ // Fringing offsets the *ramp position* per channel rather than re-sampling the field three
707
+ // times: three palette lookups are nearly free, three more fbm evaluations would triple the
708
+ // program's cost for a decorative edge.
709
+ vec3 color;
710
+ if (u_chromatic > 0.0) {
711
+ float d = u_chromatic * 0.04;
712
+ color = vec3(
713
+ kuiPalette(wrapped + d).r,
714
+ kuiPalette(wrapped).g,
715
+ kuiPalette(wrapped - d).b
716
+ );
717
+ } else {
718
+ color = kuiPalette(wrapped);
719
+ }
720
+
721
+ if (u_hue != 0.0) color = kuiHueRotate(color, u_hue);
722
+
723
+ // \`hover: light\` \u2014 a torch held over the surface.
724
+ //
725
+ // A multiply rather than a blend toward white. Multiplying preserves the hue and *raises* the
726
+ // saturation, so what appears under the pointer is the author's own colour becoming vivid;
727
+ // mixing toward white would wash the palette out at exactly the point the eye is on. The gain is
728
+ // 1.0 in the far field, which is what makes this a highlight rather than a restyling \u2014 a palette
729
+ // already at the top of the range has nowhere to go, and that is the honest behaviour of a torch.
730
+ if ((u_hover & KUI_HOVER_LIGHT) != 0) color *= 1.0 + 0.7 * hoverFall;
731
+
732
+ if (u_grain > 0.0) color += kuiGrain(gl_FragCoord.xy, t) * u_grain * 0.25;
733
+
734
+ // Opaque before masking, and premultiplied trivially because alpha is 1 \u2014 the element's own
735
+ // shape and its ancestors' opacity arrive through shapeMask(), the mark's shape through
736
+ // glyphMask(), and both multiply all four channels, which is the right operation on
737
+ // premultiplied colour and is what leaves the page visible around a logo.
738
+ float sm = shapeMask();
739
+ float gm = glyphMask();
740
+ vec4 px = vec4(clamp(color, 0.0, 1.0), 1.0) * u_tint * sm * gm;
741
+
742
+ // \`backdrop\` \u2014 the colour the field is composited *over*, inside the element's own shape.
743
+ //
744
+ // Premultiplied "over", because everything in this program already is. \`u_tint.a * gm\` is the
745
+ // field's coverage before the shape mask, so the backdrop is pulled through the same \`sm\` as the
746
+ // field rather than being masked twice \u2014 which would otherwise thin the backdrop along an
747
+ // antialiased rounded corner.
748
+ //
749
+ // What it is *for*: \`mode: logo\`'s plate. Outside the mark the coverage is zero and the backdrop
750
+ // is all there is, so a mark gets a solid ground instead of the page showing through. A bare
751
+ // \`gradient\` is opaque, so there this is a no-op by construction \u2014 visible only through a \`tint\`
752
+ // whose alpha is below 1. That is a property of an opaque field, not an oversight.
753
+ if (u_backdrop.a > 0.0) {
754
+ px += vec4(u_backdrop.rgb * u_backdrop.a, u_backdrop.a) * sm * (1.0 - u_tint.a * gm);
755
+ }
756
+ fragColor = px;
757
+ }
758
+ `;
759
+
760
+ // src/advanced/base.ts
761
+ function clamp(val, min, max) {
762
+ return Math.min(Math.max(val, min), max);
763
+ }
764
+ function styleOf(ledgers, el) {
765
+ if (!el || typeof el.hasAttribute !== "function") return null;
766
+ if (!el.style) return null;
767
+ return ledgers.style(el);
768
+ }
769
+ function createAdvancedLedgers(host) {
770
+ const claim = createStyleClaim();
771
+ return {
772
+ style: (el) => claim.style(el),
773
+ /*
774
+ * One element's teardown must not cost the rest of the subtree's.
775
+ *
776
+ * This loop is the *only* thing that ever gives a found element back — a `camera-layer`, a
777
+ * `scene-step` — because nothing else holds a claim on it. `createStyleLedger.restore()` ends
778
+ * in `removeProperty`/`setProperty`/`removeAttribute` calls straight against the author's
779
+ * element, and any of those can throw on a page that patched the CSSOM, froze a node, or runs a
780
+ * `MutationObserver` that throws in the same task. An unguarded loop therefore turned one such
781
+ * element into *every element after it in the claim set*, host included, keeping this library's
782
+ * inline transforms permanently: `openClaimBook.leave` drops the claim before the restore that
783
+ * threw, so those elements were never coming back — no later owner can be the last one out of
784
+ * an entry the failed sweep already deleted, and `createEffectInstance.destroy()` latches
785
+ * `isDestroyed` before `opts.destroy()`, so a second `destroy()` cannot retry either.
786
+ *
787
+ * The failures are collected rather than swallowed: `destroy()` throwing is contained upstream
788
+ * (`js-effect-preparer.ts` warns and moves on; the animator's `runQuietly` swallows), and an
789
+ * element the library genuinely could not hand back is worth saying so about. Aggregated so the
790
+ * first failure does not hide the fifth.
791
+ */
792
+ restore() {
793
+ const failures = [];
794
+ const release = (el) => {
795
+ try {
796
+ claim.release(el);
797
+ } catch (error) {
798
+ failures.push(error);
799
+ }
800
+ };
801
+ for (const el of claim.elements()) {
802
+ if (el !== host) release(el);
803
+ }
804
+ release(host);
805
+ if (failures.length > 0) {
806
+ throw new AggregateError(
807
+ failures,
808
+ `kuinetic: ${failures.length} element(s) could not be given back`
809
+ );
810
+ }
811
+ },
812
+ elements: () => claim.elements()
813
+ };
814
+ }
815
+ var effectNamePatterns = /* @__PURE__ */ new Map();
816
+ function effectNamePattern(name) {
817
+ let pattern = effectNamePatterns.get(name);
818
+ if (!pattern) {
819
+ pattern = new RegExp(String.raw`(?:^|,)\s*${name}(?=[\s,]|$)`);
820
+ effectNamePatterns.set(name, pattern);
821
+ }
822
+ return pattern;
823
+ }
824
+ function declaresEffect(el, name) {
825
+ if (typeof el.getAttribute !== "function") return false;
826
+ const attr = el.getAttribute("data-kui");
827
+ return Boolean(attr) && effectNamePattern(name).test(attr);
828
+ }
829
+ function isNearestHostOfKind(host, el, hostEffect) {
830
+ let node = el?.parentElement;
831
+ while (node && node !== host) {
832
+ if (declaresEffect(node, hostEffect)) return false;
833
+ node = node.parentElement;
834
+ }
835
+ return true;
836
+ }
837
+ function ownedDescendants(host, hostEffect, descendantEffect) {
838
+ if (typeof host.querySelectorAll !== "function") return [];
839
+ const owned = [];
840
+ for (const el of host.querySelectorAll(`[data-kui*="${descendantEffect}"]`)) {
841
+ if (declaresEffect(el, descendantEffect) && isNearestHostOfKind(host, el, hostEffect)) owned.push(el);
842
+ }
843
+ return owned;
844
+ }
845
+ function lerp(start, end, progress) {
846
+ return start + (end - start) * progress;
847
+ }
848
+ function defaultRaf(win, fn) {
849
+ if (win && typeof win.requestAnimationFrame === "function") {
850
+ return win.requestAnimationFrame(fn);
851
+ }
852
+ return null;
853
+ }
854
+ function defaultCaf(win, id) {
855
+ if (win && typeof win.cancelAnimationFrame === "function" && id != null) {
856
+ win.cancelAnimationFrame(id);
857
+ }
858
+ return null;
859
+ }
860
+ function defaultSetTimer(fn, ms) {
861
+ if (typeof globalThis.setTimeout !== "function") return null;
862
+ return globalThis.setTimeout(fn, ms);
863
+ }
864
+ function defaultClearTimer(id) {
865
+ if (typeof globalThis.clearTimeout === "function" && id != null) {
866
+ globalThis.clearTimeout(id);
867
+ }
868
+ }
869
+ function resolveWindow(ctx, env) {
870
+ if (ctx?.win !== void 0) return ctx.win;
871
+ if (env.window !== void 0) return env.window;
872
+ return typeof window !== "undefined" ? window : null;
873
+ }
874
+ function resolveDocument(ctx, env) {
875
+ if (ctx?.doc !== void 0) return ctx.doc;
876
+ if (env.document !== void 0) return env.document;
877
+ return typeof document !== "undefined" ? document : null;
878
+ }
879
+ var defaultCanvasFactories = /* @__PURE__ */ new WeakMap();
880
+ var noDocumentCanvas = () => null;
881
+ function resolveCreateCanvas(c, env, doc) {
882
+ if (c?.createCanvas) return c.createCanvas;
883
+ if (env.createCanvas) return env.createCanvas;
884
+ if (!doc) return noDocumentCanvas;
885
+ const cached = defaultCanvasFactories.get(doc);
886
+ if (cached) return cached;
887
+ const factory = () => typeof doc.createElement === "function" ? doc.createElement("canvas") : null;
888
+ defaultCanvasFactories.set(doc, factory);
889
+ return factory;
890
+ }
891
+ function resolveRaf(c, env, win) {
892
+ if (c?.raf) return c.raf;
893
+ if (env.raf) return env.raf;
894
+ return (fn) => defaultRaf(win, fn);
895
+ }
896
+ function resolveCaf(c, env, win) {
897
+ if (c?.caf) return c.caf;
898
+ if (env.caf) return env.caf;
899
+ return (id) => defaultCaf(win, id);
900
+ }
901
+ function resolveTimers(c, env) {
902
+ if (c?.setTimer && c.clearTimer) return [c.setTimer, c.clearTimer];
903
+ if (env.setTimer && env.clearTimer) return [env.setTimer, env.clearTimer];
904
+ return [defaultSetTimer, defaultClearTimer];
905
+ }
906
+ function resolveEnv(ctx = null, env = {}) {
907
+ const c = ctx;
908
+ const win = resolveWindow(c, env);
909
+ const doc = resolveDocument(c, env);
910
+ const [setTimer, clearTimer] = resolveTimers(c, env);
911
+ return {
912
+ window: win,
913
+ document: doc,
914
+ createCanvas: resolveCreateCanvas(c, env, doc),
915
+ raf: resolveRaf(c, env, win),
916
+ caf: resolveCaf(c, env, win),
917
+ setTimer,
918
+ clearTimer
919
+ };
920
+ }
921
+ function isReducedMotion(ctx) {
922
+ if (!ctx) return false;
923
+ const rm = ctx.reducedMotion;
924
+ if (rm === true) return true;
925
+ if (typeof rm === "object" && rm && rm.enabled === true) return true;
926
+ return false;
927
+ }
928
+ function createEffectInstance(opts = {}) {
929
+ let isDestroyed = false;
930
+ let isFinished = false;
931
+ let resolveFinished;
932
+ const finishedPromise = new Promise((resolve) => {
933
+ resolveFinished = resolve;
934
+ });
935
+ return {
936
+ continuous: Boolean(opts.continuous),
937
+ activate() {
938
+ if (isDestroyed) return;
939
+ if (typeof opts.activate === "function") opts.activate();
940
+ },
941
+ cancel() {
942
+ if (isDestroyed) return;
943
+ if (typeof opts.cancel === "function") opts.cancel();
944
+ if (!isFinished && resolveFinished) {
945
+ isFinished = true;
946
+ resolveFinished();
947
+ }
948
+ },
949
+ finish() {
950
+ if (isDestroyed) return;
951
+ if (typeof opts.finish === "function") opts.finish();
952
+ if (!isFinished && resolveFinished) {
953
+ isFinished = true;
954
+ resolveFinished();
955
+ }
956
+ },
957
+ finished: opts.continuous ? Promise.resolve() : finishedPromise,
958
+ /*
959
+ * `finally`, because `destroy()` is the one call that cannot be made twice.
960
+ *
961
+ * `cancel()` and `finish()` throwing leave the instance alive, so a later call still settles
962
+ * `finished`. `destroy()` latches `isDestroyed` first — deliberately, so that a teardown which
963
+ * threw partway cannot be re-entered and a later `activate()` cannot resurrect a destroyed
964
+ * effect through ledgers that rejoin on write (`owned-styles.ts`). The cost of that latch was
965
+ * that a throwing `opts.destroy()` also skipped the resolve below, and every subsequent call
966
+ * returned at the guard: a non-continuous instance's `finished` then never settled at all, so
967
+ * anything awaiting it waited for the life of the page. The teardown still throws — the caller
968
+ * is told — but the promise it advertises is honoured on the way out.
969
+ */
970
+ destroy() {
971
+ if (isDestroyed) return;
972
+ isDestroyed = true;
973
+ try {
974
+ if (typeof opts.destroy === "function") opts.destroy();
975
+ } finally {
976
+ if (!isFinished && resolveFinished) {
977
+ isFinished = true;
978
+ resolveFinished();
979
+ }
980
+ }
981
+ }
982
+ };
983
+ }
984
+ function createInertInstance(destroy) {
985
+ let isDestroyed = false;
986
+ return {
987
+ continuous: false,
988
+ activate() {
989
+ },
990
+ cancel() {
991
+ },
992
+ finish() {
993
+ },
994
+ finished: Promise.resolve(),
995
+ destroy() {
996
+ if (isDestroyed) return;
997
+ isDestroyed = true;
998
+ if (typeof destroy === "function") destroy();
999
+ }
1000
+ };
1001
+ }
1002
+ function toList(val) {
1003
+ return Array.isArray(val) ? val : [val];
1004
+ }
1005
+ function hasRegistrarShape(value) {
1006
+ if (!value || typeof value !== "object") return false;
1007
+ const v = value;
1008
+ return typeof v.registerPrimitives === "function" && typeof v.registerPresets === "function";
1009
+ }
1010
+ function asRegistry(target) {
1011
+ if (!target || typeof target !== "object") return void 0;
1012
+ if (hasRegistrarShape(target)) return target;
1013
+ const host = target;
1014
+ if (hasRegistrarShape(host.registry)) return host.registry;
1015
+ return void 0;
1016
+ }
1017
+ function registerInto(target, primitives, presets, name = "module") {
1018
+ const reg = asRegistry(target);
1019
+ if (!reg) {
1020
+ throw new Error(`kuinetic: register${name} requires a Registry or Animator instance`);
1021
+ }
1022
+ reg.registerPrimitives(toList(primitives));
1023
+ reg.registerPresets(toList(presets));
1024
+ return target;
1025
+ }
1026
+
1027
+ // src/advanced/gl-utils.ts
1028
+ function compileShader(gl, type, source) {
1029
+ const shader = gl.createShader(type);
1030
+ if (!shader) return null;
1031
+ gl.shaderSource(shader, source);
1032
+ gl.compileShader(shader);
1033
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1034
+ gl.deleteShader(shader);
1035
+ return null;
1036
+ }
1037
+ return shader;
1038
+ }
1039
+ function createProgram(gl, vsSource, fsSource) {
1040
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vsSource);
1041
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
1042
+ if (!vs || !fs) {
1043
+ if (vs) gl.deleteShader(vs);
1044
+ if (fs) gl.deleteShader(fs);
1045
+ return null;
1046
+ }
1047
+ const program = gl.createProgram();
1048
+ if (!program) {
1049
+ gl.deleteShader(vs);
1050
+ gl.deleteShader(fs);
1051
+ return null;
1052
+ }
1053
+ gl.attachShader(program, vs);
1054
+ gl.attachShader(program, fs);
1055
+ gl.linkProgram(program);
1056
+ gl.deleteShader(vs);
1057
+ gl.deleteShader(fs);
1058
+ if (typeof gl.getProgramParameter === "function" && !gl.getProgramParameter(program, gl.LINK_STATUS)) {
1059
+ gl.deleteProgram(program);
1060
+ return null;
1061
+ }
1062
+ return program;
1063
+ }
1064
+ function extractLocations(gl, program) {
1065
+ if (!program) return null;
1066
+ return {
1067
+ program,
1068
+ a_position: gl.getAttribLocation(program, "a_position"),
1069
+ u_image: gl.getUniformLocation(program, "u_image"),
1070
+ u_image_to: gl.getUniformLocation(program, "u_image_to"),
1071
+ u_uvOrigin: gl.getUniformLocation(program, "u_uvOrigin"),
1072
+ u_uvScale: gl.getUniformLocation(program, "u_uvScale"),
1073
+ u_maskBox: gl.getUniformLocation(program, "u_maskBox"),
1074
+ u_maskRx: gl.getUniformLocation(program, "u_maskRx"),
1075
+ u_maskRy: gl.getUniformLocation(program, "u_maskRy"),
1076
+ u_maskAlpha: gl.getUniformLocation(program, "u_maskAlpha"),
1077
+ u_time: gl.getUniformLocation(program, "u_time"),
1078
+ u_strength: gl.getUniformLocation(program, "u_strength"),
1079
+ u_frequency: gl.getUniformLocation(program, "u_frequency"),
1080
+ u_chromatic: gl.getUniformLocation(program, "u_chromatic"),
1081
+ u_iridescence: gl.getUniformLocation(program, "u_iridescence"),
1082
+ u_mouse: gl.getUniformLocation(program, "u_mouse"),
1083
+ u_tint: gl.getUniformLocation(program, "u_tint"),
1084
+ u_color1: gl.getUniformLocation(program, "u_color1"),
1085
+ u_color2: gl.getUniformLocation(program, "u_color2"),
1086
+ u_duotone: gl.getUniformLocation(program, "u_duotone"),
1087
+ u_blend: gl.getUniformLocation(program, "u_blend"),
1088
+ u_progress: gl.getUniformLocation(program, "u_progress"),
1089
+ u_seed: gl.getUniformLocation(program, "u_seed"),
1090
+ u_scale: gl.getUniformLocation(program, "u_scale"),
1091
+ u_warp: gl.getUniformLocation(program, "u_warp"),
1092
+ u_grain: gl.getUniformLocation(program, "u_grain"),
1093
+ u_hue: gl.getUniformLocation(program, "u_hue"),
1094
+ u_detail: gl.getUniformLocation(program, "u_detail"),
1095
+ u_bands: gl.getUniformLocation(program, "u_bands"),
1096
+ u_colorCount: gl.getUniformLocation(program, "u_colorCount"),
1097
+ u_colors: gl.getUniformLocation(program, "u_colors"),
1098
+ // Which stencil the generative program applies: 0 none (`gradient`), 1 alpha, 2 luma,
1099
+ // 3 inverted luma. See `glyphMask()` in `glsl.ts`.
1100
+ u_maskMode: gl.getUniformLocation(program, "u_maskMode"),
1101
+ u_hover: gl.getUniformLocation(program, "u_hover"),
1102
+ u_backdrop: gl.getUniformLocation(program, "u_backdrop"),
1103
+ u_angle: gl.getUniformLocation(program, "u_angle"),
1104
+ u_motion: gl.getUniformLocation(program, "u_motion")
1105
+ };
1106
+ }
1107
+ function disposeGLResources(gl, quadBuffer, programs) {
1108
+ if (!gl) return;
1109
+ if (quadBuffer && gl.deleteBuffer) gl.deleteBuffer(quadBuffer);
1110
+ for (const info of Object.values(programs)) {
1111
+ const prog = info && typeof info === "object" && "program" in info ? info.program : info;
1112
+ if (prog && gl.deleteProgram) gl.deleteProgram(prog);
1113
+ }
1114
+ }
1115
+ function sourceExtent(source) {
1116
+ const s = source;
1117
+ const w = Number(s.naturalWidth ?? s.videoWidth ?? s.width ?? 0);
1118
+ const h = Number(s.naturalHeight ?? s.videoHeight ?? s.height ?? 0);
1119
+ return [Number.isFinite(w) ? w : 0, Number.isFinite(h) ? h : 0];
1120
+ }
1121
+ function exceedsTextureLimit(gl, source) {
1122
+ if (typeof gl.getParameter !== "function") return false;
1123
+ const limit = Number(gl.getParameter(gl.MAX_TEXTURE_SIZE));
1124
+ if (!Number.isFinite(limit) || limit <= 0) return false;
1125
+ const [w, h] = sourceExtent(source);
1126
+ return w > limit || h > limit;
1127
+ }
1128
+ function drainGLErrors(gl) {
1129
+ if (typeof gl.getError !== "function") return;
1130
+ for (let i = 0; i < 8; i += 1) {
1131
+ if (Number(gl.getError()) === 0) return;
1132
+ }
1133
+ }
1134
+ function glRaisedError(gl) {
1135
+ if (typeof gl.getError !== "function") return false;
1136
+ const code = gl.getError();
1137
+ return typeof code === "number" && code !== 0;
1138
+ }
1139
+ function createGLTexture(gl, source) {
1140
+ if (exceedsTextureLimit(gl, source)) return null;
1141
+ const tex = gl.createTexture();
1142
+ if (!tex) return null;
1143
+ drainGLErrors(gl);
1144
+ try {
1145
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1146
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1147
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1148
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1149
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1150
+ if (typeof gl.pixelStorei === "function") gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
1151
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
1152
+ if (glRaisedError(gl)) {
1153
+ if (gl.deleteTexture) gl.deleteTexture(tex);
1154
+ return null;
1155
+ }
1156
+ return tex;
1157
+ } catch {
1158
+ if (gl.deleteTexture) gl.deleteTexture(tex);
1159
+ return null;
1160
+ }
1161
+ }
1162
+
1163
+ // src/advanced/audio.ts
1164
+ var AUDIO_CHANNELS = [
1165
+ "--kui-audio-bass",
1166
+ "--kui-audio-mid",
1167
+ "--kui-audio-treble",
1168
+ "--kui-audio-level",
1169
+ "--kui-audio"
1170
+ ];
1171
+ var AUDIO_BAND_PROPERTIES = {
1172
+ bass: "--kui-audio-bass",
1173
+ mid: "--kui-audio-mid",
1174
+ treble: "--kui-audio-treble",
1175
+ level: "--kui-audio-level"
1176
+ };
1177
+ var AUDIO_BANDS = Object.keys(AUDIO_BAND_PROPERTIES);
1178
+ function parseBandValue(raw) {
1179
+ if (!raw) return null;
1180
+ const val = parseFloat(raw);
1181
+ return Number.isFinite(val) ? clamp(val, 0, 1) : null;
1182
+ }
1183
+ function parseAudioBand(raw) {
1184
+ if (!raw) return null;
1185
+ const name = raw.trim();
1186
+ return Object.hasOwn(AUDIO_BAND_PROPERTIES, name) ? name : null;
1187
+ }
1188
+ function readAudioBand(el, band) {
1189
+ const prop = AUDIO_BAND_PROPERTIES[band];
1190
+ const fromInline = parseBandValue(el.style?.getPropertyValue?.(prop));
1191
+ if (fromInline !== null) return fromInline;
1192
+ const view = el.ownerDocument?.defaultView;
1193
+ return parseBandValue(view?.getComputedStyle?.(el).getPropertyValue(prop)) ?? 0;
1194
+ }
1195
+ function computeFrequencyBands(data, sampleRate) {
1196
+ const binCount = data.length;
1197
+ if (binCount === 0) return { bass: 0, mid: 0, treble: 0, level: 0 };
1198
+ const nyquist = sampleRate > 0 ? sampleRate / 2 : 22050;
1199
+ const hzPerBin = nyquist / binCount;
1200
+ const getAverage = (minHz, maxHz) => {
1201
+ const startBin = Math.max(0, Math.floor(minHz / hzPerBin));
1202
+ const endBin = Math.min(binCount, Math.max(startBin + 1, Math.ceil(maxHz / hzPerBin)));
1203
+ let sum = 0;
1204
+ let count = 0;
1205
+ for (let i = startBin; i < endBin; i++) {
1206
+ sum += data[i];
1207
+ count++;
1208
+ }
1209
+ return count > 0 ? sum / (count * 255) : 0;
1210
+ };
1211
+ let totalSum = 0;
1212
+ for (let i = 0; i < binCount; i++) totalSum += data[i];
1213
+ const level = totalSum / (binCount * 255);
1214
+ return {
1215
+ bass: clamp(getAverage(20, 250), 0, 1),
1216
+ mid: clamp(getAverage(250, 4e3), 0, 1),
1217
+ treble: clamp(getAverage(4e3, 16e3), 0, 1),
1218
+ level: clamp(level, 0, 1)
1219
+ };
1220
+ }
1221
+ var DEFAULT_MEDIA_SELECTOR = "audio, video";
1222
+ function isMediaElement(el, win) {
1223
+ const injected = win?.HTMLMediaElement;
1224
+ const ctor = injected ?? (typeof HTMLMediaElement !== "undefined" ? HTMLMediaElement : null);
1225
+ return ctor ? el instanceof ctor : false;
1226
+ }
1227
+ function findMediaElement(el, authored, win, doc) {
1228
+ if (isMediaElement(el, win)) return el;
1229
+ const local = el.querySelector?.(authored ?? DEFAULT_MEDIA_SELECTOR);
1230
+ if (local) return local;
1231
+ if (!authored) return null;
1232
+ return doc?.querySelector?.(authored) || null;
1233
+ }
1234
+ function safeDisconnect(node) {
1235
+ if (!node || typeof node.disconnect !== "function") return;
1236
+ try {
1237
+ node.disconnect();
1238
+ } catch {
1239
+ return;
1240
+ }
1241
+ }
1242
+ function safeDisconnectFrom(node, destination) {
1243
+ if (!node || typeof node.disconnect !== "function" || !destination) return;
1244
+ try {
1245
+ node.disconnect(destination);
1246
+ } catch {
1247
+ return;
1248
+ }
1249
+ }
1250
+ var FFT_SIZES = [32, 64, 128, 256, 512, 1024, 2048];
1251
+ function snapFftSize(requested) {
1252
+ let best = FFT_SIZES[0];
1253
+ for (const size of FFT_SIZES) {
1254
+ if (Math.abs(size - requested) < Math.abs(best - requested)) best = size;
1255
+ }
1256
+ return best;
1257
+ }
1258
+ var GESTURE_EVENTS = ["pointerdown", "keydown", "touchend"];
1259
+ var sharedGraphs = /* @__PURE__ */ new WeakMap();
1260
+ function createMutedSink(ctx) {
1261
+ if (typeof ctx.createGain !== "function") return null;
1262
+ try {
1263
+ const gain = ctx.createGain();
1264
+ if (gain.gain) gain.gain.value = 0;
1265
+ gain.connect(ctx.destination);
1266
+ return gain;
1267
+ } catch {
1268
+ return null;
1269
+ }
1270
+ }
1271
+ function acquireSharedGraph(win) {
1272
+ const existing = sharedGraphs.get(win);
1273
+ if (existing) {
1274
+ existing.consumers++;
1275
+ return existing;
1276
+ }
1277
+ const AudioCtxCtor = win.AudioContext || win.webkitAudioContext;
1278
+ if (!AudioCtxCtor) return null;
1279
+ try {
1280
+ const ctx = new AudioCtxCtor();
1281
+ const graph = {
1282
+ ctx,
1283
+ sources: /* @__PURE__ */ new WeakMap(),
1284
+ sink: createMutedSink(ctx),
1285
+ consumers: 1,
1286
+ ownsMediaOutput: false
1287
+ };
1288
+ sharedGraphs.set(win, graph);
1289
+ return graph;
1290
+ } catch {
1291
+ return null;
1292
+ }
1293
+ }
1294
+ function releaseSharedGraph(win, graph) {
1295
+ graph.consumers--;
1296
+ if (graph.consumers > 0 || graph.ownsMediaOutput) return;
1297
+ sharedGraphs.delete(win);
1298
+ const closing = graph.ctx.close?.();
1299
+ if (closing) closing.catch(() => void 0);
1300
+ }
1301
+ function mediaSourceFor(graph, mediaEl) {
1302
+ const existing = graph.sources.get(mediaEl);
1303
+ if (existing) return existing;
1304
+ if (typeof graph.ctx.createMediaElementSource !== "function") return null;
1305
+ try {
1306
+ const source = graph.ctx.createMediaElementSource(mediaEl);
1307
+ source.connect(graph.ctx.destination);
1308
+ graph.sources.set(mediaEl, source);
1309
+ graph.ownsMediaOutput = true;
1310
+ return source;
1311
+ } catch {
1312
+ return null;
1313
+ }
1314
+ }
1315
+ var AudioSourceController = class {
1316
+ element;
1317
+ options;
1318
+ env;
1319
+ window;
1320
+ document;
1321
+ raf;
1322
+ caf;
1323
+ audioCtx = null;
1324
+ analyser = null;
1325
+ sourceNode = null;
1326
+ stream = null;
1327
+ freqData = null;
1328
+ rafId = null;
1329
+ isActive = false;
1330
+ /** The window-wide graph this instance is currently counted against. */
1331
+ graph = null;
1332
+ /**
1333
+ * Whether `sourceNode` belongs to the shared graph rather than to this instance.
1334
+ *
1335
+ * True for `source: media` — the node feeds the page's speakers as well as this analyser, so
1336
+ * teardown may only cut its edge to *this* analyser. False for `source: mic`, where the node and
1337
+ * its stream are this instance's alone and must be released in full.
1338
+ */
1339
+ sourceIsShared = false;
1340
+ /**
1341
+ * The author's inline custom properties, and what this module replaced them with.
1342
+ *
1343
+ * Not a snapshot taken here: the ledger for `element` is opened at the first write in
1344
+ * `updateFrame`, so what it restores is whatever the author had at that instant. The field it
1345
+ * replaced captured all five properties in this constructor and restored them on destroy, which
1346
+ * meant an author who wrote `--kui-audio-bass` any time after `prepare` had that write silently
1347
+ * reverted to a value from before their own.
1348
+ *
1349
+ * Shared with every other controller writing to this element — see `createAdvancedLedgers`. Two
1350
+ * `audio-source` effects on one element (`audio-reactive` plus `audio-mic`, say) would otherwise
1351
+ * open a ledger each, and the second would capture the first's `0.000` as the author's value.
1352
+ */
1353
+ ledgers;
1354
+ /**
1355
+ * Which microphone request is the live one.
1356
+ *
1357
+ * `getUserMedia` cannot be cancelled — the permission prompt belongs to the browser, and the
1358
+ * promise settles whenever the visitor answers it. Every `stop()` bumps this, so a request the
1359
+ * visitor grants *after* the effect was cancelled resolves into a handler that no longer
1360
+ * recognises its own generation and stops the tracks instead of connecting them. Without it,
1361
+ * cancelling while the prompt is open and then granting leaves the microphone recording until
1362
+ * the element is eventually destroyed.
1363
+ */
1364
+ micRequestToken = 0;
1365
+ /** Detaches the one-shot gesture listeners armed by {@link armGestureResume}. */
1366
+ releaseGestureResume = null;
1367
+ constructor(element, options = {}, env = {}) {
1368
+ this.element = element;
1369
+ this.options = options;
1370
+ this.env = env;
1371
+ const resolved = resolveEnv(null, env);
1372
+ this.window = resolved.window;
1373
+ this.document = resolved.document;
1374
+ this.raf = resolved.raf;
1375
+ this.caf = resolved.caf;
1376
+ this.ledgers = createAdvancedLedgers(element);
1377
+ }
1378
+ connectMicSource(graph, analyser, win) {
1379
+ const nav = win?.navigator;
1380
+ if (!nav?.mediaDevices?.getUserMedia) return;
1381
+ const token = ++this.micRequestToken;
1382
+ nav.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
1383
+ if (token !== this.micRequestToken || this.analyser !== analyser) {
1384
+ for (const track of stream.getTracks()) track.stop();
1385
+ return;
1386
+ }
1387
+ this.stream = stream;
1388
+ this.sourceNode = graph.ctx.createMediaStreamSource(stream);
1389
+ this.sourceIsShared = false;
1390
+ this.sourceNode.connect(analyser);
1391
+ if (this.resumeContext()) this.startLoopIfReady();
1392
+ }).catch(() => void 0);
1393
+ }
1394
+ connectMediaSource(graph, analyser, win) {
1395
+ const doc = this.document;
1396
+ try {
1397
+ const mediaEl = findMediaElement(this.element, this.options.target || null, win, doc);
1398
+ if (!mediaEl) return;
1399
+ const source = mediaSourceFor(graph, mediaEl);
1400
+ if (!source) return;
1401
+ source.connect(analyser);
1402
+ this.sourceNode = source;
1403
+ this.sourceIsShared = true;
1404
+ } catch {
1405
+ return;
1406
+ }
1407
+ }
1408
+ /**
1409
+ * This instance's analyser, already on a live path to the destination.
1410
+ *
1411
+ * @returns The analyser, or `null` when the context refused to build one.
1412
+ */
1413
+ buildAnalyser(graph) {
1414
+ try {
1415
+ const analyser = graph.ctx.createAnalyser();
1416
+ analyser.fftSize = snapFftSize(this.options.fftSize || 256);
1417
+ analyser.smoothingTimeConstant = this.options.smoothing ?? 0.8;
1418
+ if (graph.sink) analyser.connect(graph.sink);
1419
+ return analyser;
1420
+ } catch {
1421
+ return null;
1422
+ }
1423
+ }
1424
+ /**
1425
+ * Join the window's audio graph and give this instance an analyser on it.
1426
+ *
1427
+ * `this.analyser`, not `this.audioCtx`, is the "already initialised" test, and nothing is
1428
+ * recorded on `this` until the analyser exists. Assigning the context first is what left a
1429
+ * half-built instance behind — an open context, no analyser, and a loop writing zeros — whenever
1430
+ * `createAnalyser` or `fftSize` threw.
1431
+ */
1432
+ initAudio() {
1433
+ if (this.analyser) return true;
1434
+ const win = this.window;
1435
+ if (!win) return false;
1436
+ const graph = acquireSharedGraph(win);
1437
+ if (!graph) return false;
1438
+ const analyser = this.buildAnalyser(graph);
1439
+ if (!analyser) {
1440
+ releaseSharedGraph(win, graph);
1441
+ return false;
1442
+ }
1443
+ this.graph = graph;
1444
+ this.audioCtx = graph.ctx;
1445
+ this.analyser = analyser;
1446
+ this.freqData = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
1447
+ if (this.options.source === "mic") this.connectMicSource(graph, analyser, win);
1448
+ else this.connectMediaSource(graph, analyser, win);
1449
+ return true;
1450
+ }
1451
+ /**
1452
+ * Go live — but only run a frame loop once there is something for it to read.
1453
+ *
1454
+ * The loop used to start unconditionally, and both ways that can be wrong were reachable:
1455
+ *
1456
+ * - **Initialisation failed.** No Web Audio, or an `AudioContext` the browser refused to build.
1457
+ * `initAudio()` is only ever called from here and here early-returns on `isActive`, so there is
1458
+ * no retry path at all — the loop sampled nothing, wrote the same `0.000` forever and could
1459
+ * never recover.
1460
+ * - **The context is suspended.** `defaultActivation` is `'load'`, so this normally runs before
1461
+ * the visitor has touched anything and the context starts suspended. The gesture retry below
1462
+ * is the fix for *that*, but the loop does not have to run while it waits: a suspended context
1463
+ * fills zeros, and {@link GESTURE_EVENTS} is `pointerdown`/`keydown`/`touchend` — a visitor who
1464
+ * only ever scrolls with a wheel fires none of them, so "while it waits" was the rest of the
1465
+ * session at 60fps.
1466
+ *
1467
+ * - **There is nothing to read.** `initAudio()` answers `true` as soon as the analyser exists,
1468
+ * and the analyser can exist with nothing connected to it: `audio-source target:#missing`, or a
1469
+ * page with no media under the element, leaves `connectMediaSource` with no node to attach;
1470
+ * `audio-mic` leaves `connectMicSource` waiting on a permission prompt the visitor may close,
1471
+ * refuse, or never answer. A loop over a disconnected analyser is the first case again — the
1472
+ * same `0.000` at 60fps, for the life of the page, with nothing coming to change it.
1473
+ *
1474
+ * One frame of silence covers all three, and it is all any of them needs: nothing is going to
1475
+ * change these five values until a resume or a stream lands, and writing them once still says
1476
+ * "there is a driver on this element and it is silent" to a consumer that would otherwise inherit
1477
+ * an ancestor's live band. The loop is then started by whichever arrives last — the `resume()`
1478
+ * that succeeds, or the microphone the visitor grants — see {@link startLoopIfReady}.
1479
+ */
1480
+ start() {
1481
+ if (this.isActive) return;
1482
+ this.isActive = true;
1483
+ if (!this.initAudio() || !this.resumeContext() || !this.sourceNode) {
1484
+ this.updateFrame();
1485
+ return;
1486
+ }
1487
+ this.startLoop();
1488
+ }
1489
+ /**
1490
+ * Wake a context the browser suspended for autoplay policy.
1491
+ *
1492
+ * `defaultActivation` is `'load'`, so the context is very often constructed before the visitor
1493
+ * has touched anything, and a context built outside a user gesture starts `suspended`. A bare
1494
+ * `resume()` there is rejected or left pending — WebKit is strict about it, and iOS is this
1495
+ * repo's main audience — after which `getByteFrequencyData` fills zeros and all five properties
1496
+ * sit at `0.000` for the rest of the session. `on:click` only worked by luck, because it happens
1497
+ * to run inside a gesture.
1498
+ *
1499
+ * So: try immediately, and arm a one-shot retry on the visitor's first gesture. The `on:click`
1500
+ * path still succeeds on the first attempt and the listeners are never needed.
1501
+ *
1502
+ * @returns `true` when the context is already live and {@link start} may run its loop now,
1503
+ * `false` when a resume has been arranged and the loop is that resume's job to start.
1504
+ */
1505
+ resumeContext() {
1506
+ const ctx = this.audioCtx;
1507
+ if (!ctx || ctx.state !== "suspended") return true;
1508
+ ctx.resume().then(() => this.startLoopIfReady()).catch(() => void 0);
1509
+ this.armGestureResume();
1510
+ return false;
1511
+ }
1512
+ /**
1513
+ * Start the loop, unless the effect was torn down while the context was still waking up — or
1514
+ * there is still nothing connected for it to read.
1515
+ *
1516
+ * `resume()` settles whenever the browser gets to it, which can be long after a `cancel()` — the
1517
+ * same race {@link micRequestToken} closes for `getUserMedia`, and with the same consequence if
1518
+ * it is left open: a resume landing after teardown would start a frame loop on a stopped
1519
+ * instance, and nothing would ever come back to stop it.
1520
+ *
1521
+ * The source check is the other half of the gate {@link start} applies: a resume that lands
1522
+ * before the microphone is granted, or on an element with no media to bind, has nothing to start
1523
+ * a loop over. When the stream does arrive, `connectMicSource` comes back through here.
1524
+ */
1525
+ startLoopIfReady() {
1526
+ if (this.isActive && this.sourceNode) this.startLoop();
1527
+ }
1528
+ armGestureResume() {
1529
+ const win = this.window;
1530
+ if (this.releaseGestureResume || !win?.addEventListener || !win.removeEventListener) return;
1531
+ const onGesture = () => {
1532
+ this.releaseGestureResume?.();
1533
+ this.audioCtx?.resume().then(() => this.startLoopIfReady()).catch(() => void 0);
1534
+ };
1535
+ this.releaseGestureResume = () => {
1536
+ this.releaseGestureResume = null;
1537
+ for (const type of GESTURE_EVENTS) win.removeEventListener?.(type, onGesture);
1538
+ };
1539
+ for (const type of GESTURE_EVENTS) win.addEventListener(type, onGesture, { passive: true });
1540
+ }
1541
+ startLoop() {
1542
+ if (this.rafId) return;
1543
+ const tick = () => {
1544
+ this.updateFrame();
1545
+ if (this.isActive) {
1546
+ this.rafId = this.raf(tick);
1547
+ } else {
1548
+ this.rafId = null;
1549
+ }
1550
+ };
1551
+ this.rafId = this.raf(tick);
1552
+ }
1553
+ updateFrame() {
1554
+ const style = styleOf(this.ledgers, this.element);
1555
+ if (!style) return;
1556
+ let bands = { bass: 0, mid: 0, treble: 0, level: 0 };
1557
+ if (this.analyser && this.freqData && this.audioCtx) {
1558
+ this.analyser.getByteFrequencyData(this.freqData);
1559
+ bands = computeFrequencyBands(this.freqData, this.audioCtx.sampleRate);
1560
+ }
1561
+ const level = bands.level.toFixed(3);
1562
+ this.writeChannel(style, "--kui-audio-bass", bands.bass.toFixed(3));
1563
+ this.writeChannel(style, "--kui-audio-mid", bands.mid.toFixed(3));
1564
+ this.writeChannel(style, "--kui-audio-treble", bands.treble.toFixed(3));
1565
+ this.writeChannel(style, "--kui-audio-level", level);
1566
+ this.writeChannel(style, "--kui-audio", level);
1567
+ }
1568
+ /**
1569
+ * One channel, written only when it would actually change anything.
1570
+ *
1571
+ * These are unregistered custom properties, so they inherit: five unconditional `setProperty`
1572
+ * calls per frame per instance dirty the whole subtree's style 60 times a second even on a page
1573
+ * whose audio is silent or whose context never resumed, where all five values are the identical
1574
+ * `0.000`.
1575
+ *
1576
+ * The comparison reads the live inline value rather than a remembered one on purpose. A cache
1577
+ * would go stale the moment the author wrote the property themselves — this module would agree
1578
+ * with its own record, skip the write, and look dead. Reading the CSSOM's own map is a lookup,
1579
+ * not a style resolution, so it costs nothing a `setProperty` would not already cost.
1580
+ *
1581
+ * `claim()` first and unconditionally, because the very first frame can legitimately skip its
1582
+ * write (the author happened to have written the same string), and the ledger still has to have
1583
+ * captured what was there to restore it.
1584
+ *
1585
+ * @complexity O(1).
1586
+ */
1587
+ writeChannel(style, property, value) {
1588
+ style.claim(property);
1589
+ if (this.element.style.getPropertyValue(property) === value) return;
1590
+ style.set(property, value);
1591
+ }
1592
+ /**
1593
+ * Give back everything this instance owns: the frame loop, its analyser, the microphone.
1594
+ *
1595
+ * This is what `cancel()` and `finish()` reach, and it is deliberately a full release of the
1596
+ * *instance's* half of the graph. A cancelled `audio-mic` that only stopped its loop left the
1597
+ * stream connected and the recording indicator lit until the element was eventually destroyed —
1598
+ * the visitor stopped the effect and the microphone stayed on.
1599
+ *
1600
+ * What it deliberately does **not** do is touch the page's own audio or the author's styles.
1601
+ * The shared media source keeps its edge to the destination (see {@link SharedAudioGraph}), and
1602
+ * the ledger is left as it is because `EffectInstance.cancel` means "stop where it is, leaving
1603
+ * the element mid-effect" — unwinding the five custom properties here would snap every rule
1604
+ * reading `var(--kui-audio-bass, 0)` back to its fallback on a pause, which is not what the
1605
+ * other five modules in this directory do. `destroy()` is where the styles go back.
1606
+ *
1607
+ * Idempotent, and safe on a controller that was never started — every step below no-ops on an
1608
+ * empty graph. It runs unguarded for that reason: the old `isActive` early-return meant a
1609
+ * controller cancelled twice, or destroyed without ever activating, skipped the parts that had
1610
+ * nothing to do with the loop.
1611
+ */
1612
+ stop() {
1613
+ this.isActive = false;
1614
+ this.micRequestToken++;
1615
+ if (this.rafId) {
1616
+ this.caf(this.rafId);
1617
+ this.rafId = null;
1618
+ }
1619
+ this.releaseGestureResume?.();
1620
+ this.disconnectAudioNodes();
1621
+ }
1622
+ disconnectAudioNodes() {
1623
+ if (this.stream) {
1624
+ for (const track of this.stream.getTracks()) track.stop();
1625
+ this.stream = null;
1626
+ }
1627
+ const analyser = this.analyser;
1628
+ if (this.sourceIsShared) safeDisconnectFrom(this.sourceNode, analyser);
1629
+ else safeDisconnect(this.sourceNode);
1630
+ this.sourceNode = null;
1631
+ this.sourceIsShared = false;
1632
+ safeDisconnect(analyser);
1633
+ this.analyser = null;
1634
+ this.freqData = null;
1635
+ this.audioCtx = null;
1636
+ if (this.graph) {
1637
+ releaseSharedGraph(this.window, this.graph);
1638
+ this.graph = null;
1639
+ }
1640
+ }
1641
+ /**
1642
+ * Everything `stop()` gives back, plus the author's inline styles.
1643
+ *
1644
+ * `try`/`finally` because the restore must not be hostage to the graph teardown: the animator
1645
+ * swallows a throwing `instance.destroy()` (`runQuietly`), and an element this module reached on
1646
+ * its own — a `camera-layer`, a `scene-step` — is in no ledger the animator will ever restore.
1647
+ * A single throw on the way out would otherwise leave the library's values on it permanently.
1648
+ */
1649
+ destroy() {
1650
+ try {
1651
+ this.stop();
1652
+ } finally {
1653
+ this.ledgers.restore();
1654
+ }
1655
+ }
1656
+ };
1657
+ function prepareAudioSource(el, params, ctx) {
1658
+ if (isReducedMotion(ctx)) return createInertInstance();
1659
+ const resolvedEnv = resolveEnv(ctx);
1660
+ const rawSource = params.text ? params.text("source", "media") : "media";
1661
+ const source = rawSource === "mic" ? "mic" : "media";
1662
+ const target = params.text ? params.text("target", "") : "";
1663
+ const smoothing = clamp(params.num ? params.num("smoothing", 0.8) : 0.8, 0, 0.99);
1664
+ const fftSize = snapFftSize(clamp(params.num ? params.num("fft", 256) : 256, 32, 2048));
1665
+ const controller = new AudioSourceController(
1666
+ el,
1667
+ { source, target, smoothing, fftSize },
1668
+ resolvedEnv
1669
+ );
1670
+ return createEffectInstance({
1671
+ continuous: true,
1672
+ activate() {
1673
+ controller.start();
1674
+ },
1675
+ cancel() {
1676
+ controller.stop();
1677
+ },
1678
+ finish() {
1679
+ controller.stop();
1680
+ },
1681
+ destroy() {
1682
+ controller.destroy();
1683
+ }
1684
+ });
1685
+ }
1686
+ var AUDIO_PARAMETERS = {
1687
+ source: { type: "keyword", default: "media", keywords: ["media", "mic"], cssProperty: "--kui-audio-source" },
1688
+ /**
1689
+ * Which media element to analyse — the library's one spelling for "this effect names another
1690
+ * element", declared exactly as every other primitive that owns the key declares it — see
1691
+ * `compile.ts`'s `liftTarget` for the current set — down to the shared `--kui-target`.
1692
+ *
1693
+ * It was `media:`, which was both a second word for `target:`'s job and a live footgun:
1694
+ * `compile.ts`'s `liftTarget` only leaves `target:` in a primitive's own parameters when that
1695
+ * primitive declares it, and otherwise *relocates the whole effect* onto the elements the
1696
+ * selector matched. So `audio-source target:#track` used to move the analyser onto the `<audio>`
1697
+ * element itself — which happens to work, silently writing the five custom properties on the
1698
+ * wrong element. Declaring the key here claims it and closes that.
1699
+ *
1700
+ * No `scope: SCOPE_PARAM` alongside it, unlike its peers: this resolves its own two-step search
1701
+ * (`findMediaElement` — the host's own subtree first, the document only for an authored
1702
+ * selector), and importing core's shared declaration would be a value import from `src/effects`
1703
+ * into a tier whose bundle weight is measured (see `base.ts`'s `asRegistry`).
1704
+ */
1705
+ target: { type: "text", default: "", cssProperty: "--kui-target" },
1706
+ smoothing: { type: "number", default: "0.8", minimum: 0, maximum: 0.99, cssProperty: "--kui-audio-smoothing" },
1707
+ fft: { type: "number", default: "256", minimum: 32, maximum: 2048, cssProperty: "--kui-audio-fft" }
1708
+ };
1709
+ var AUDIO_PRIMITIVES = [
1710
+ {
1711
+ id: "audio-source",
1712
+ renderer: "javascript",
1713
+ // The five custom properties `updateFrame` writes, named exactly. `[]` said this primitive
1714
+ // touched nothing, so the registry's conflict detection could not see a second source writing
1715
+ // the same variables and let the two race, last frame wins.
1716
+ channels: [...AUDIO_CHANNELS],
1717
+ parameters: AUDIO_PARAMETERS,
1718
+ supportedTimelines: ["time"],
1719
+ // `manual` alongside the existing three (same additive shape as `interaction-proximity.ts`
1720
+ // and `interaction.ts`): `on:load` (the default) leaves `resumeContext`'s one-shot
1721
+ // page-gesture listener as the fallback, but an author who wants the AudioContext to wake on
1722
+ // one specific, deliberate gesture — a "start visualiser" button, not the visitor's next
1723
+ // incidental tap anywhere on the page — can now write `on:manual` and call `.play()` from
1724
+ // that button's own handler, which still lands inside the same user gesture the browser
1725
+ // requires.
1726
+ supportedActivations: ["load", "enter", "click", "manual"],
1727
+ defaultActivation: "load",
1728
+ perfClass: "continuous",
1729
+ reducedMotion: "disable",
1730
+ prepare: prepareAudioSource
1731
+ }
1732
+ ];
1733
+ var AUDIO_PRESETS = [
1734
+ { name: "audio-source", primitive: "audio-source" },
1735
+ { name: "audio-reactive", primitive: "audio-source" },
1736
+ { name: "audio-mic", primitive: "audio-source", params: { source: "mic" } }
1737
+ ];
1738
+ function registerAudio(target) {
1739
+ return registerInto(target, AUDIO_PRIMITIVES[0], AUDIO_PRESETS, "AudioSource");
1740
+ }
1741
+
1742
+ // src/advanced/shaders.ts
1743
+ function parseHex(raw) {
1744
+ if (!/^[0-9a-fA-F]{3,8}$/.test(raw)) return null;
1745
+ if (raw.length === 3 || raw.length === 4) {
1746
+ const a = raw.length === 4 ? parseInt(raw[3] + raw[3], 16) / 255 : 1;
1747
+ return [parseInt(raw[0] + raw[0], 16) / 255, parseInt(raw[1] + raw[1], 16) / 255, parseInt(raw[2] + raw[2], 16) / 255, a];
1748
+ }
1749
+ if (raw.length === 6 || raw.length === 8) {
1750
+ const a = raw.length === 8 ? parseInt(raw.slice(6, 8), 16) / 255 : 1;
1751
+ return [parseInt(raw.slice(0, 2), 16) / 255, parseInt(raw.slice(2, 4), 16) / 255, parseInt(raw.slice(4, 6), 16) / 255, a];
1752
+ }
1753
+ return null;
1754
+ }
1755
+ var RGBA_REGEX = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i;
1756
+ function parseRgbFunctional(val) {
1757
+ const match = RGBA_REGEX.exec(val);
1758
+ if (!match || !match[1] || !match[2] || !match[3]) return null;
1759
+ const r = Math.min(255, Math.max(0, parseInt(match[1], 10))) / 255;
1760
+ const g = Math.min(255, Math.max(0, parseInt(match[2], 10))) / 255;
1761
+ const b = Math.min(255, Math.max(0, parseInt(match[3], 10))) / 255;
1762
+ const a = match[4] !== void 0 ? Math.min(1, Math.max(0, parseFloat(match[4]))) : 1;
1763
+ return [r, g, b, a];
1764
+ }
1765
+ var NAMED_COLORS = {
1766
+ transparent: [0, 0, 0, 0],
1767
+ black: [0, 0, 0, 1],
1768
+ white: [1, 1, 1, 1],
1769
+ red: [1, 0, 0, 1],
1770
+ green: [0, 0.5, 0, 1],
1771
+ blue: [0, 0, 1, 1],
1772
+ yellow: [1, 1, 0, 1],
1773
+ cyan: [0, 1, 1, 1],
1774
+ magenta: [1, 0, 1, 1],
1775
+ gray: [0.5, 0.5, 0.5, 1],
1776
+ grey: [0.5, 0.5, 0.5, 1],
1777
+ orange: [1, 0.647, 0, 1],
1778
+ purple: [0.5, 0, 0.5, 1]
1779
+ };
1780
+ var colorContexts = /* @__PURE__ */ new WeakMap();
1781
+ function colorContextFor(createCanvas) {
1782
+ const cached = colorContexts.get(createCanvas);
1783
+ if (cached !== void 0) return cached;
1784
+ let ctx = null;
1785
+ try {
1786
+ const canvas = createCanvas();
1787
+ if (canvas) {
1788
+ canvas.width = 1;
1789
+ canvas.height = 1;
1790
+ ctx = canvas.getContext?.("2d", { willReadFrequently: true }) ?? null;
1791
+ }
1792
+ } catch {
1793
+ ctx = null;
1794
+ }
1795
+ if (ctx) colorContexts.set(createCanvas, ctx);
1796
+ return ctx;
1797
+ }
1798
+ function parseColorWithCanvas(val, createCanvas) {
1799
+ try {
1800
+ const ctx = colorContextFor(createCanvas);
1801
+ if (!ctx) return null;
1802
+ ctx.clearRect(0, 0, 1, 1);
1803
+ ctx.fillStyle = "#000000";
1804
+ ctx.fillStyle = val;
1805
+ ctx.fillRect(0, 0, 1, 1);
1806
+ const data = ctx.getImageData(0, 0, 1, 1).data;
1807
+ return [data[0] / 255, data[1] / 255, data[2] / 255, data[3] / 255];
1808
+ } catch {
1809
+ return null;
1810
+ }
1811
+ }
1812
+ function parseColor(str, createCanvas = resolveEnv(null).createCanvas) {
1813
+ if (!str) return [1, 1, 1, 1];
1814
+ const val = str.trim();
1815
+ if (val.startsWith("#")) {
1816
+ const hex = parseHex(val.slice(1));
1817
+ if (hex) return hex;
1818
+ }
1819
+ const rgb = parseRgbFunctional(val);
1820
+ if (rgb) return rgb;
1821
+ const lower = val.toLowerCase();
1822
+ if (lower in NAMED_COLORS) return NAMED_COLORS[lower];
1823
+ const canvasColor = parseColorWithCanvas(val, createCanvas);
1824
+ if (canvasColor) return canvasColor;
1825
+ return [1, 1, 1, 1];
1826
+ }
1827
+ var ANGLE_UNITS = {
1828
+ deg: Math.PI / 180,
1829
+ grad: Math.PI / 200,
1830
+ rad: 1,
1831
+ turn: Math.PI * 2
1832
+ };
1833
+ function parseAngleRadians(raw) {
1834
+ if (!raw) return 0;
1835
+ const match = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))(deg|grad|rad|turn)?$/i.exec(raw.trim());
1836
+ if (!match) return 0;
1837
+ const value = parseFloat(match[1]);
1838
+ if (!Number.isFinite(value)) return 0;
1839
+ return value * (ANGLE_UNITS[(match[2] ?? "deg").toLowerCase()] ?? ANGLE_UNITS.deg);
1840
+ }
1841
+ var PALETTE_SIZE = 5;
1842
+ var DEFAULT_PALETTE = [
1843
+ [0.09, 0.11, 0.25, 1],
1844
+ [0.45, 0.29, 0.78, 1]
1845
+ ];
1846
+ function buildPalette(authored, createCanvas) {
1847
+ const set = authored.filter((raw) => raw.trim() !== "").slice(0, PALETTE_SIZE);
1848
+ const stops = set.length >= 2 ? set.map((raw) => parseColor(raw, createCanvas)) : [...DEFAULT_PALETTE];
1849
+ const palette = new Float32Array(PALETTE_SIZE * 4);
1850
+ for (let i = 0; i < PALETTE_SIZE; i += 1) {
1851
+ const stop = stops[Math.min(i, stops.length - 1)];
1852
+ palette.set(stop, i * 4);
1853
+ }
1854
+ return { palette, colorCount: stops.length };
1855
+ }
1856
+ function cacheLocalLocations(gl, locs, program) {
1857
+ if (locs.u_audio === void 0) locs.u_audio = gl.getUniformLocation(program, "u_audio");
1858
+ if (locs.u_noise === void 0) locs.u_noise = gl.getUniformLocation(program, "u_noise");
1859
+ return locs;
1860
+ }
1861
+ function resolveLocations(gl, progInfo) {
1862
+ if (!progInfo) return null;
1863
+ if ("program" in progInfo && progInfo.program) return cacheLocalLocations(gl, progInfo, progInfo.program);
1864
+ const p = progInfo;
1865
+ const g = (n) => gl.getUniformLocation(p, n);
1866
+ return {
1867
+ u_time: g("u_time"),
1868
+ u_strength: g("u_strength"),
1869
+ u_frequency: g("u_frequency"),
1870
+ u_chromatic: g("u_chromatic"),
1871
+ u_iridescence: g("u_iridescence"),
1872
+ u_mouse: g("u_mouse"),
1873
+ u_uvOrigin: g("u_uvOrigin"),
1874
+ u_uvScale: g("u_uvScale"),
1875
+ u_tint: g("u_tint"),
1876
+ u_blend: g("u_blend"),
1877
+ u_duotone: g("u_duotone"),
1878
+ u_color1: g("u_color1"),
1879
+ u_color2: g("u_color2"),
1880
+ u_image: g("u_image"),
1881
+ u_image_to: g("u_image_to"),
1882
+ u_progress: g("u_progress"),
1883
+ u_audio: g("u_audio"),
1884
+ u_noise: g("u_noise"),
1885
+ u_maskBox: g("u_maskBox"),
1886
+ u_maskRx: g("u_maskRx"),
1887
+ u_maskRy: g("u_maskRy"),
1888
+ u_maskAlpha: g("u_maskAlpha"),
1889
+ u_seed: g("u_seed"),
1890
+ u_scale: g("u_scale"),
1891
+ u_warp: g("u_warp"),
1892
+ u_grain: g("u_grain"),
1893
+ u_hue: g("u_hue"),
1894
+ u_detail: g("u_detail"),
1895
+ u_bands: g("u_bands"),
1896
+ u_colorCount: g("u_colorCount"),
1897
+ u_colors: g("u_colors"),
1898
+ // `u_maskMode` was missing from this branch while `extractLocations` looked it up, so an
1899
+ // external caller handing this module a bare `WebGLProgram` got a generative draw with no
1900
+ // stencil. Latent — everything inside this file goes through `extractLocations` — but the two
1901
+ // lists are supposed to answer the same question, and one of them was a stop short.
1902
+ u_maskMode: g("u_maskMode"),
1903
+ u_hover: g("u_hover"),
1904
+ u_backdrop: g("u_backdrop"),
1905
+ u_angle: g("u_angle"),
1906
+ u_motion: g("u_motion")
1907
+ };
1908
+ }
1909
+ var NO_MASK_BOX = [0, 0, 0, 0];
1910
+ var NO_BACKDROP = [0, 0, 0, 0];
1911
+ function uploadMaskUniforms(gl, locs, opt) {
1912
+ if (locs.u_maskBox) gl.uniform4fv(locs.u_maskBox, opt.maskBox ?? NO_MASK_BOX);
1913
+ if (locs.u_maskRx) gl.uniform4fv(locs.u_maskRx, opt.maskRx ?? NO_MASK_BOX);
1914
+ if (locs.u_maskRy) gl.uniform4fv(locs.u_maskRy, opt.maskRy ?? NO_MASK_BOX);
1915
+ uploadFloat(gl, locs.u_maskAlpha, opt.maskAlpha ?? 1);
1916
+ }
1917
+ function uploadFloat(gl, loc, val) {
1918
+ if (loc && val !== void 0) gl.uniform1f(loc, val);
1919
+ }
1920
+ function uploadInt(gl, loc, val) {
1921
+ if (loc && val !== void 0) gl.uniform1i(loc, Math.round(val));
1922
+ }
1923
+ function uploadFieldSamplingUniforms(gl, locs, opt) {
1924
+ uploadFloat(gl, locs.u_seed, opt.seed ?? 0);
1925
+ uploadFloat(gl, locs.u_scale, opt.scale ?? 1);
1926
+ uploadFloat(gl, locs.u_warp, opt.warp ?? 0);
1927
+ uploadFloat(gl, locs.u_noise, opt.noise ?? 0);
1928
+ }
1929
+ function uploadFieldTextureUniforms(gl, locs, opt) {
1930
+ uploadFloat(gl, locs.u_grain, opt.grain ?? 0);
1931
+ uploadFloat(gl, locs.u_hue, opt.hue ?? 0);
1932
+ uploadInt(gl, locs.u_detail, opt.detail ?? 3);
1933
+ uploadInt(gl, locs.u_bands, opt.bands ?? 0);
1934
+ }
1935
+ function uploadFieldBranchUniforms(gl, locs, opt) {
1936
+ uploadInt(gl, locs.u_maskMode, opt.maskMode ?? 0);
1937
+ uploadInt(gl, locs.u_hover, opt.hover ?? 0);
1938
+ uploadFloat(gl, locs.u_angle, opt.angle ?? 0);
1939
+ uploadInt(gl, locs.u_motion, opt.motion ?? 0);
1940
+ }
1941
+ function uploadFieldUniforms(gl, locs, opt) {
1942
+ uploadFieldSamplingUniforms(gl, locs, opt);
1943
+ uploadFieldTextureUniforms(gl, locs, opt);
1944
+ uploadFieldBranchUniforms(gl, locs, opt);
1945
+ if (locs.u_backdrop) gl.uniform4fv(locs.u_backdrop, opt.backdropRgba ?? NO_BACKDROP);
1946
+ }
1947
+ function uploadCoordUniforms(gl, locs, opt) {
1948
+ if (locs.u_mouse && opt.localMouse) gl.uniform2f(locs.u_mouse, opt.localMouse.x, opt.localMouse.y);
1949
+ if (locs.u_uvOrigin && opt.uvOrigin) gl.uniform2f(locs.u_uvOrigin, opt.uvOrigin[0], opt.uvOrigin[1]);
1950
+ if (locs.u_uvScale && opt.uvScale) gl.uniform2f(locs.u_uvScale, opt.uvScale[0], opt.uvScale[1]);
1951
+ }
1952
+ function uploadUniforms(gl, progInfo, opt) {
1953
+ const locs = resolveLocations(gl, progInfo);
1954
+ if (!locs) return;
1955
+ const timeVal = opt.time !== void 0 ? opt.time * (opt.speed ?? 1) : void 0;
1956
+ uploadFloat(gl, locs.u_time, timeVal);
1957
+ uploadFloat(gl, locs.u_strength, opt.strength);
1958
+ uploadFloat(gl, locs.u_frequency, opt.frequency);
1959
+ uploadFloat(gl, locs.u_chromatic, opt.chromatic);
1960
+ uploadFloat(gl, locs.u_iridescence, opt.iridescence);
1961
+ uploadFloat(gl, locs.u_progress, opt.progress ?? -1);
1962
+ uploadFloat(gl, locs.u_audio, opt.audio ?? 0);
1963
+ uploadCoordUniforms(gl, locs, opt);
1964
+ uploadMaskUniforms(gl, locs, opt);
1965
+ uploadFieldUniforms(gl, locs, opt);
1966
+ }
1967
+ function uploadDuotoneUniforms(gl, locs, opt) {
1968
+ if (!opt.isDuotone) return;
1969
+ if (locs.u_color1 && opt.c1Rgba) gl.uniform4fv(locs.u_color1, opt.c1Rgba);
1970
+ if (locs.u_color2 && opt.c2Rgba) gl.uniform4fv(locs.u_color2, opt.c2Rgba);
1971
+ }
1972
+ function uploadColorUniforms(gl, progInfo, opt) {
1973
+ const locs = resolveLocations(gl, progInfo);
1974
+ if (!locs) return;
1975
+ if (locs.u_tint && opt.tintRgba) gl.uniform4fv(locs.u_tint, opt.tintRgba);
1976
+ if (locs.u_blend && opt.blendMode !== void 0) gl.uniform1i(locs.u_blend, opt.blendMode);
1977
+ if (locs.u_duotone) gl.uniform1f(locs.u_duotone, opt.isDuotone ? 1 : 0);
1978
+ uploadDuotoneUniforms(gl, locs, opt);
1979
+ if (locs.u_colors && opt.palette) gl.uniform4fv(locs.u_colors, opt.palette);
1980
+ uploadInt(gl, locs.u_colorCount, opt.colorCount);
1981
+ }
1982
+ function isRectVisible(rect, winHeight) {
1983
+ if (rect.width <= 0 || rect.height <= 0) return false;
1984
+ return rect.bottom >= 0 && rect.top <= winHeight;
1985
+ }
1986
+ function getScissorEnv(win) {
1987
+ return {
1988
+ height: win?.innerHeight ?? 800,
1989
+ dpr: Math.min(win?.devicePixelRatio ?? 1, 2)
1990
+ };
1991
+ }
1992
+ function numOr(raw, fallback = 0) {
1993
+ const val = parseFloat(raw ?? "");
1994
+ return Number.isFinite(val) ? val : fallback;
1995
+ }
1996
+ function cssStyleOf(el, win) {
1997
+ const gcs = win?.getComputedStyle;
1998
+ if (typeof gcs !== "function") return null;
1999
+ try {
2000
+ return gcs.call(win, el);
2001
+ } catch {
2002
+ return null;
2003
+ }
2004
+ }
2005
+ function boxOf(rect) {
2006
+ return { left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height };
2007
+ }
2008
+ function intersectBox(a, b) {
2009
+ return {
2010
+ left: Math.max(a.left, b.left),
2011
+ top: Math.max(a.top, b.top),
2012
+ right: Math.min(a.right, b.right),
2013
+ bottom: Math.min(a.bottom, b.bottom)
2014
+ };
2015
+ }
2016
+ function insetBox(box, by) {
2017
+ return {
2018
+ left: box.left + by.left,
2019
+ top: box.top + by.top,
2020
+ right: box.right - by.right,
2021
+ bottom: box.bottom - by.bottom
2022
+ };
2023
+ }
2024
+ function borderWidthsOf(cs) {
2025
+ return {
2026
+ left: numOr(cs.borderLeftWidth),
2027
+ top: numOr(cs.borderTopWidth),
2028
+ right: numOr(cs.borderRightWidth),
2029
+ bottom: numOr(cs.borderBottomWidth)
2030
+ };
2031
+ }
2032
+ function contentBoxOf(rect, cs) {
2033
+ const border = boxOf(rect);
2034
+ if (!cs) return border;
2035
+ const edges = borderWidthsOf(cs);
2036
+ return insetBox(border, {
2037
+ left: edges.left + numOr(cs.paddingLeft),
2038
+ top: edges.top + numOr(cs.paddingTop),
2039
+ right: edges.right + numOr(cs.paddingRight),
2040
+ bottom: edges.bottom + numOr(cs.paddingBottom)
2041
+ });
2042
+ }
2043
+ function fittedSize(fit, bw, bh, natural) {
2044
+ const [iw, ih] = natural;
2045
+ if (fit === "cover") {
2046
+ const s = Math.max(bw / iw, bh / ih);
2047
+ return [iw * s, ih * s];
2048
+ }
2049
+ if (fit === "none") return [iw, ih];
2050
+ if (fit === "contain" || fit === "scale-down") {
2051
+ const s = Math.min(bw / iw, bh / ih, fit === "scale-down" ? 1 : Infinity);
2052
+ return [iw * s, ih * s];
2053
+ }
2054
+ return [bw, bh];
2055
+ }
2056
+ var POSITION_KEYWORDS = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 };
2057
+ function positionOffset(token, free) {
2058
+ const keyword = POSITION_KEYWORDS[token];
2059
+ if (keyword !== void 0) return free * keyword;
2060
+ if (token.endsWith("%")) return free * (numOr(token) / 100);
2061
+ return numOr(token, free * 0.5);
2062
+ }
2063
+ function paintBoxOf(content, natural, cs) {
2064
+ if (!natural || !cs) return content;
2065
+ const bw = content.right - content.left;
2066
+ const bh = content.bottom - content.top;
2067
+ if (bw <= 0 || bh <= 0) return content;
2068
+ const [dw, dh] = fittedSize(cs.objectFit || "fill", bw, bh, natural);
2069
+ const tokens = (cs.objectPosition || "50% 50%").trim().split(/\s+/);
2070
+ const left = content.left + positionOffset(tokens[0] ?? "50%", bw - dw);
2071
+ const top = content.top + positionOffset(tokens[1] ?? "50%", bh - dh);
2072
+ return { left, top, right: left + dw, bottom: top + dh };
2073
+ }
2074
+ function radiusPart(token, basis) {
2075
+ if (!token) return 0;
2076
+ return token.endsWith("%") ? Math.max(0, basis * (numOr(token) / 100)) : Math.max(0, numOr(token));
2077
+ }
2078
+ function cornerRadius(raw, bw, bh) {
2079
+ const parts = (raw ?? "").trim().split(/\s+/);
2080
+ return [radiusPart(parts[0], bw), radiusPart(parts[1] ?? parts[0], bh)];
2081
+ }
2082
+ function radiiOf(cs, rect) {
2083
+ if (!cs) return [0, 0, 0, 0, 0, 0, 0, 0];
2084
+ const w = rect.width, h = rect.height;
2085
+ const tl = cornerRadius(cs.borderTopLeftRadius, w, h);
2086
+ const tr = cornerRadius(cs.borderTopRightRadius, w, h);
2087
+ const br = cornerRadius(cs.borderBottomRightRadius, w, h);
2088
+ const bl = cornerRadius(cs.borderBottomLeftRadius, w, h);
2089
+ return [tl[0], tl[1], tr[0], tr[1], br[0], br[1], bl[0], bl[1]];
2090
+ }
2091
+ function naturalSizeOf(el) {
2092
+ const { naturalWidth: w, naturalHeight: h } = el;
2093
+ if (typeof w !== "number" || typeof h !== "number" || w <= 0 || h <= 0) return null;
2094
+ return [w, h];
2095
+ }
2096
+ function isClipping(cs) {
2097
+ for (const overflow of [cs.overflowX, cs.overflowY]) {
2098
+ if (overflow && overflow !== "visible") return true;
2099
+ }
2100
+ return false;
2101
+ }
2102
+ var ANCESTOR_WALK_LIMIT = 12;
2103
+ function isContainingBlock(cs) {
2104
+ if (cs.position && cs.position !== "static") return true;
2105
+ return [cs.transform, cs.filter, cs.perspective].some((v) => v && v !== "none");
2106
+ }
2107
+ function clipsSubject(subject, cs) {
2108
+ if (subject === "fixed") return false;
2109
+ if (subject === "absolute") return isContainingBlock(cs);
2110
+ return true;
2111
+ }
2112
+ function narrowByAncestor(acc, node, win) {
2113
+ const cs = cssStyleOf(node, win);
2114
+ if (!cs) return;
2115
+ const opacity = numOr(cs.opacity, 1);
2116
+ if (opacity < 1) acc.alpha *= Math.max(0, opacity);
2117
+ if (clipsSubject(acc.subject, cs)) {
2118
+ const rect = isClipping(cs) ? node.getBoundingClientRect?.() : null;
2119
+ if (rect) acc.clip = intersectBox(acc.clip, insetBox(boxOf(rect), borderWidthsOf(cs)));
2120
+ acc.subject = cs.position || "static";
2121
+ }
2122
+ }
2123
+ function walkAncestors(el, win, start, subject) {
2124
+ const acc = { clip: start, alpha: 1, subject };
2125
+ let node = el.parentElement ?? null;
2126
+ for (let depth = 0; node && depth < ANCESTOR_WALK_LIMIT; depth++) {
2127
+ narrowByAncestor(acc, node, win);
2128
+ node = node.parentElement ?? null;
2129
+ }
2130
+ return acc;
2131
+ }
2132
+ function measureElementGeometry(el, win, fullBox = false, hostAlpha) {
2133
+ const rect = el.getBoundingClientRect();
2134
+ const { height } = getScissorEnv(win);
2135
+ if (!isRectVisible(rect, height)) return null;
2136
+ const view = win ?? null;
2137
+ const cs = cssStyleOf(el, view);
2138
+ const border = boxOf(rect);
2139
+ const content = fullBox ? border : contentBoxOf(rect, cs);
2140
+ const walked = walkAncestors(el, view, content, cs?.position || "static");
2141
+ const own = Math.max(0, Math.min(1, hostAlpha ?? numOr(cs?.opacity, 1)));
2142
+ return {
2143
+ border,
2144
+ paint: fullBox ? border : paintBoxOf(content, naturalSizeOf(el), cs),
2145
+ clip: walked.clip,
2146
+ radii: radiiOf(cs, rect),
2147
+ alpha: walked.alpha * own,
2148
+ hostAlpha: own
2149
+ };
2150
+ }
2151
+ function maskUniformsOf(geom, canvasHeight, dpr) {
2152
+ const b = geom.border;
2153
+ const r = geom.radii;
2154
+ return {
2155
+ maskBox: [
2156
+ (b.left + b.right) * 0.5 * dpr,
2157
+ canvasHeight - (b.top + b.bottom) * 0.5 * dpr,
2158
+ (b.right - b.left) * 0.5 * dpr,
2159
+ (b.bottom - b.top) * 0.5 * dpr
2160
+ ],
2161
+ maskRx: [r[0] * dpr, r[2] * dpr, r[4] * dpr, r[6] * dpr],
2162
+ maskRy: [r[1] * dpr, r[3] * dpr, r[5] * dpr, r[7] * dpr]
2163
+ };
2164
+ }
2165
+ function resolveDrawBox(geom, canvas, dpr) {
2166
+ const paintW = geom.paint.right - geom.paint.left;
2167
+ const paintH = geom.paint.bottom - geom.paint.top;
2168
+ if (paintW <= 0 || paintH <= 0) return null;
2169
+ const view = { left: 0, top: 0, right: canvas.width / dpr, bottom: canvas.height / dpr };
2170
+ const draw = intersectBox(intersectBox(geom.paint, geom.clip), view);
2171
+ const drawW = draw.right - draw.left;
2172
+ const drawH = draw.bottom - draw.top;
2173
+ if (drawW <= 0 || drawH <= 0) return null;
2174
+ const sx = Math.round(draw.left * dpr);
2175
+ const sy = Math.round(canvas.height - draw.bottom * dpr);
2176
+ const sw = Math.min(canvas.width - sx, Math.round(drawW * dpr));
2177
+ const sh = Math.min(canvas.height - sy, Math.round(drawH * dpr));
2178
+ if (sw <= 0 || sh <= 0) return null;
2179
+ const b = geom.border;
2180
+ return {
2181
+ sx,
2182
+ sy,
2183
+ sw,
2184
+ sh,
2185
+ rect: { left: b.left, top: b.top, width: b.right - b.left, height: b.bottom - b.top },
2186
+ uvOrigin: [(draw.left - geom.paint.left) / paintW, (draw.top - geom.paint.top) / paintH],
2187
+ uvScale: [drawW / paintW, drawH / paintH],
2188
+ alpha: geom.alpha,
2189
+ ...maskUniformsOf(geom, canvas.height, dpr)
2190
+ };
2191
+ }
2192
+ function applyDrawBox(gl, canvas, geom, dpr) {
2193
+ const box = resolveDrawBox(geom, canvas, dpr);
2194
+ if (!box) return null;
2195
+ gl.enable(gl.SCISSOR_TEST);
2196
+ gl.viewport(box.sx, box.sy, box.sw, box.sh);
2197
+ gl.scissor(box.sx, box.sy, box.sw, box.sh);
2198
+ return box;
2199
+ }
2200
+ function bindShaderTexture(gl, progInfo, texture, toTexture) {
2201
+ if (!texture) return;
2202
+ const locs = resolveLocations(gl, progInfo);
2203
+ gl.activeTexture(gl.TEXTURE0);
2204
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2205
+ if (locs?.u_image) gl.uniform1i(locs.u_image, 0);
2206
+ if (locs?.u_image_to) {
2207
+ gl.activeTexture(gl.TEXTURE1);
2208
+ gl.bindTexture(gl.TEXTURE_2D, toTexture || texture);
2209
+ gl.uniform1i(locs.u_image_to, 1);
2210
+ }
2211
+ }
2212
+ function getProgram(info) {
2213
+ if (!info) return null;
2214
+ return "program" in info && info.program ? info.program : info;
2215
+ }
2216
+ function computeMousePos(renderer, rect) {
2217
+ if (renderer.hasPointer === false) return { x: 0.5, y: 0.5 };
2218
+ const mx = renderer.mouse.x - rect.left;
2219
+ const my = renderer.mouse.y - rect.top;
2220
+ return {
2221
+ x: clamp(mx / rect.width, 0, 1),
2222
+ y: clamp(my / rect.height, 0, 1)
2223
+ };
2224
+ }
2225
+ function bindQuadAttrib(gl, quadBuffer, info) {
2226
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
2227
+ const locs = info;
2228
+ if (locs?.a_position !== void 0 && locs.a_position !== -1) {
2229
+ gl.enableVertexAttribArray(locs.a_position);
2230
+ gl.vertexAttribPointer(locs.a_position, 2, gl.FLOAT, false, 0, 0);
2231
+ }
2232
+ }
2233
+ function drawTargets(renderer) {
2234
+ const { gl, canvas, quadBuffer } = renderer;
2235
+ return gl && canvas && quadBuffer ? { gl, canvas } : null;
2236
+ }
2237
+ function drawElementQuad(renderer, el, opt = {}) {
2238
+ const target = drawTargets(renderer);
2239
+ if (!target) return false;
2240
+ const { gl, canvas } = target;
2241
+ const mode = opt.mode ?? "displace";
2242
+ const info = renderer.programs[mode];
2243
+ if (info === void 0) return false;
2244
+ const program = getProgram(info);
2245
+ if (!program) return false;
2246
+ const geom = opt.geometry !== void 0 ? opt.geometry : measureElementGeometry(el, renderer.window, GENERATIVE_MODES.has(mode));
2247
+ if (!geom) return false;
2248
+ const scissor = applyDrawBox(gl, canvas, geom, getScissorEnv(renderer.window).dpr);
2249
+ if (!scissor) return false;
2250
+ const localMouse = computeMousePos(renderer, scissor.rect);
2251
+ gl.useProgram(program);
2252
+ bindShaderTexture(gl, info, opt.texture, opt.toTexture);
2253
+ uploadUniforms(gl, info, {
2254
+ ...opt,
2255
+ localMouse,
2256
+ uvOrigin: scissor.uvOrigin,
2257
+ uvScale: scissor.uvScale,
2258
+ maskBox: scissor.maskBox,
2259
+ maskRx: scissor.maskRx,
2260
+ maskRy: scissor.maskRy,
2261
+ maskAlpha: scissor.alpha
2262
+ });
2263
+ uploadColorUniforms(gl, info, opt);
2264
+ bindQuadAttrib(gl, renderer.quadBuffer, info);
2265
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
2266
+ return true;
2267
+ }
2268
+ var GENERATIVE_MODES = /* @__PURE__ */ new Set(["gradient"]);
2269
+ var SHADER_MODES = ["displace", "fluid", "liquid", "particles", "morph", "gradient", "logo"];
2270
+ var PROGRAM_SOURCES = [
2271
+ [["displace"], DISPLACE_FS],
2272
+ [["fluid"], FLUID_FS],
2273
+ [["liquid"], LIQUID_FS],
2274
+ [["particles"], PARTICLES_FS],
2275
+ [["morph"], MORPH_FS],
2276
+ [["gradient", "logo"], GRADIENT_FS]
2277
+ ];
2278
+ var MASK_MODES = { alpha: 1, luma: 2, "luma-invert": 3 };
2279
+ var HOVER_MODES = { none: 0, stir: 1, light: 2, both: 3 };
2280
+ var MOTION_MODES = { evolve: 0, drift: 1, swirl: 2 };
2281
+ function readShaderMode(params) {
2282
+ return "keyword" in params && typeof params.keyword === "function" ? params.keyword("mode") : params.text("mode", "displace");
2283
+ }
2284
+ function readMaskMode(params, mode) {
2285
+ return mode === "logo" ? MASK_MODES[params.text("mask", "alpha")] ?? MASK_MODES.alpha : 0;
2286
+ }
2287
+ function readDuotoneColors(c1Text, c2Text, createCanvas) {
2288
+ return {
2289
+ isDuotone: Boolean(c1Text && c2Text),
2290
+ c1Rgba: parseColor(c1Text || "#000000", createCanvas),
2291
+ c2Rgba: parseColor(c2Text || "#ffffff", createCanvas)
2292
+ };
2293
+ }
2294
+ function extractShaderOptions(params, createCanvas) {
2295
+ const mode = readShaderMode(params);
2296
+ const c1Text = params.text("color1", "");
2297
+ const c2Text = params.text("color2", "");
2298
+ const backdropText = params.text("backdrop", "").trim();
2299
+ const blendMap = { normal: 0, screen: 1, multiply: 2, add: 3 };
2300
+ const { palette, colorCount } = buildPalette([
2301
+ c1Text,
2302
+ c2Text,
2303
+ params.text("color3", ""),
2304
+ params.text("color4", ""),
2305
+ params.text("color5", "")
2306
+ ], createCanvas);
2307
+ return {
2308
+ mode,
2309
+ seed: params.num("seed", 0),
2310
+ scale: params.num("scale", 1),
2311
+ warp: params.num("warp", 0),
2312
+ noise: params.num("noise", 0),
2313
+ grain: params.num("grain", 0),
2314
+ hue: parseAngleRadians(params.text("hue", "0deg")),
2315
+ detail: params.num("detail", 3),
2316
+ bands: params.num("bands", 0),
2317
+ palette,
2318
+ colorCount,
2319
+ maskMode: readMaskMode(params, mode),
2320
+ hover: HOVER_MODES[params.text("hover", "none")] ?? 0,
2321
+ angle: parseAngleRadians(params.text("angle", "0deg")),
2322
+ motion: MOTION_MODES[params.text("motion", "evolve")] ?? 0,
2323
+ // Read through the raw string rather than straight into `parseColor`, which answers opaque
2324
+ // white for an empty input — the fallback that makes an unset `color1` harmless would make an
2325
+ // unset `backdrop` a white plate behind every logo on the page.
2326
+ backdropRgba: backdropText ? parseColor(backdropText, createCanvas) : [0, 0, 0, 0],
2327
+ strength: params.num("strength", 0.5),
2328
+ speed: params.num("speed", 1),
2329
+ frequency: params.num("frequency", 10),
2330
+ chromatic: params.num("chromatic", 0),
2331
+ iridescence: params.num("iridescence", 0),
2332
+ tintRgba: parseColor(params.text("tint", "#ffffff"), createCanvas),
2333
+ ...readDuotoneColors(c1Text, c2Text, createCanvas),
2334
+ blendMode: blendMap[params.text("blend", "normal")] ?? 0,
2335
+ to: params.text("to", ""),
2336
+ progress: params.num ? params.num("progress", -1) : -1,
2337
+ // Compared against the one enabling word rather than "not `off`", so — exactly as with
2338
+ // `audioBand` below — a caller passing an unvalidated accessor, or an author who typed
2339
+ // `scrub: scrol`, cannot turn the bridge on by accident.
2340
+ scrubsFromScroll: params.text("scrub", "off") === "scroll",
2341
+ // `parseAudioBand` answers `null` for `off`, for an empty string, and for any word that is not
2342
+ // a band, so a caller passing an unvalidated accessor cannot turn the feature on by accident.
2343
+ audioBand: parseAudioBand(params.text("audio", "off"))
2344
+ };
2345
+ }
2346
+ function parseProgressValue(raw) {
2347
+ if (!raw) return null;
2348
+ const val = parseFloat(raw);
2349
+ return Number.isFinite(val) ? clamp(val, 0, 1) : null;
2350
+ }
2351
+ function readElementProgress(el) {
2352
+ const fromInline = parseProgressValue(el.style?.getPropertyValue?.("--kui-progress"));
2353
+ if (fromInline !== null) return fromInline;
2354
+ const view = el.ownerDocument?.defaultView;
2355
+ const fromComputed = parseProgressValue(view?.getComputedStyle?.(el).getPropertyValue("--kui-progress"));
2356
+ return fromComputed !== null ? fromComputed : -1;
2357
+ }
2358
+ var nextShaderId = 0;
2359
+ function invokeIsolated(fn) {
2360
+ try {
2361
+ fn();
2362
+ } catch {
2363
+ return;
2364
+ }
2365
+ }
2366
+ function syncCanvasDimensions(canvas, win, dpr) {
2367
+ const vw = win?.innerWidth ?? 1e3;
2368
+ const vh = win?.innerHeight ?? 800;
2369
+ const cw = Math.round(vw * dpr);
2370
+ const ch = Math.round(vh * dpr);
2371
+ if (canvas.width !== cw || canvas.height !== ch) {
2372
+ canvas.width = cw;
2373
+ canvas.height = ch;
2374
+ }
2375
+ const style = canvas.style;
2376
+ if (!style) return;
2377
+ const cssW = `${vw}px`;
2378
+ const cssH = `${vh}px`;
2379
+ if (style.width !== cssW) style.width = cssW;
2380
+ if (style.height !== cssH) style.height = cssH;
2381
+ }
2382
+ var DEFAULT_SHADER_Z_INDEX = 1;
2383
+ var SHADER_Z_PROPERTY = "--kui-shader-z";
2384
+ function readShaderZIndex(doc, win) {
2385
+ const root = doc?.documentElement;
2386
+ if (!root) return DEFAULT_SHADER_Z_INDEX;
2387
+ const inline = parseZIndexValue(root.style?.getPropertyValue?.(SHADER_Z_PROPERTY));
2388
+ return inline ?? readComputedZIndex(root, win) ?? DEFAULT_SHADER_Z_INDEX;
2389
+ }
2390
+ function readComputedZIndex(root, win) {
2391
+ return parseZIndexValue(win?.getComputedStyle?.(root)?.getPropertyValue?.(SHADER_Z_PROPERTY));
2392
+ }
2393
+ function parseZIndexValue(raw) {
2394
+ const trimmed = raw?.trim();
2395
+ if (!trimmed) return null;
2396
+ const n = Number(trimmed);
2397
+ return Number.isFinite(n) ? Math.trunc(n) : null;
2398
+ }
2399
+ var SharedShaderRenderer = class {
2400
+ env;
2401
+ window;
2402
+ document;
2403
+ createCanvas;
2404
+ raf;
2405
+ caf;
2406
+ canvas = null;
2407
+ gl = null;
2408
+ quadBuffer = null;
2409
+ rafId = null;
2410
+ drawCalls = /* @__PURE__ */ new Map();
2411
+ contextCallbacks = /* @__PURE__ */ new Map();
2412
+ /**
2413
+ * One style-reading pass per registered instance — scroll progress and, when the author asked
2414
+ * for one, an audio band — run in full before any instance's draw call this frame. See
2415
+ * `renderFrame` for why the ordering, not just the reads themselves, is the point.
2416
+ */
2417
+ inputReaders = /* @__PURE__ */ new Map();
2418
+ programs = {};
2419
+ refCount = 0;
2420
+ mouse = { x: 0, y: 0 };
2421
+ /** Cleared until a real `pointermove` lands. See {@link computeMousePos}. */
2422
+ hasPointer = false;
2423
+ isContextLost = false;
2424
+ /**
2425
+ * Bumped every time this renderer's GL objects stop being valid, i.e. on each context loss.
2426
+ *
2427
+ * It exists because "is this handle still real?" has no other answer. A `WebGLTexture` from a lost
2428
+ * context is an ordinary live JavaScript object — binding it raises `INVALID_OPERATION` and
2429
+ * samples nothing, and neither the draw nor `drawElementQuad`'s return value can tell.
2430
+ *
2431
+ * The hole it closes: `cancel()`/`finish()` unregister an instance, which takes its entry out of
2432
+ * `contextCallbacks` with it, so an *inactive* instance is never told the context went away. Its
2433
+ * texture holders kept answering with pre-loss handles, and `acquireRenderer` short-circuits on
2434
+ * re-activation because the renderer itself was never destroyed — so a hover, a hover-out, a
2435
+ * context loss and a second hover drew a blank replica over a hidden image, permanently.
2436
+ *
2437
+ * Stamping the holders rather than keeping every acquired instance registered for context events:
2438
+ * the stamp is checked where the stale handle is actually read, so it holds however the instance
2439
+ * came to miss the event, and it costs two compares per holder per frame. See
2440
+ * {@link initTextureHolder}.
2441
+ */
2442
+ contextGeneration = 0;
2443
+ mapKey;
2444
+ onPointer;
2445
+ onContextLost;
2446
+ onContextRestored;
2447
+ constructor(env = {}) {
2448
+ this.env = env;
2449
+ const r = resolveEnv(null, env);
2450
+ this.window = r.window;
2451
+ this.document = r.document;
2452
+ this.createCanvas = r.createCanvas;
2453
+ this.raf = r.raf;
2454
+ this.caf = r.caf;
2455
+ this.mapKey = r.document ?? r.window ?? fallbackShaderKey;
2456
+ }
2457
+ /**
2458
+ * Whether `destroy()` has run. A destroyed renderer never comes back to life.
2459
+ *
2460
+ * `destroy()` un-maps itself, so the object an already-prepared instance is still holding is no
2461
+ * longer the one `getSharedShaderRenderer` hands out. Letting `acquire()` re-`init()` that object
2462
+ * therefore built a *second* full-viewport canvas and WebGL2 context in one document — and then
2463
+ * a third, because the next element to be prepared found nothing in the map and made its own,
2464
+ * and whichever of them tore down next deleted the shared document key from under the live one.
2465
+ * Cycling shader elements on an SPA route walked straight into the browser's 8-16 context cap.
2466
+ *
2467
+ * So a teardown is final, and `RendererRef` is what gets an orphaned instance back onto the live
2468
+ * renderer.
2469
+ */
2470
+ isDestroyed = false;
2471
+ acquire() {
2472
+ if (this.isDestroyed) return false;
2473
+ if (this.canvas && this.isContextLost) {
2474
+ this.refCount++;
2475
+ return true;
2476
+ }
2477
+ if (this.gl && !this.isContextLost) {
2478
+ this.refCount++;
2479
+ return true;
2480
+ }
2481
+ const ok = this.init();
2482
+ if (ok) this.refCount++;
2483
+ return ok;
2484
+ }
2485
+ release() {
2486
+ if (this.refCount > 0) this.refCount--;
2487
+ if (this.refCount === 0) this.destroy();
2488
+ }
2489
+ init() {
2490
+ if (!this.document?.body?.appendChild) return false;
2491
+ this.canvas = this.createCanvas();
2492
+ if (!this.canvas) return false;
2493
+ this.canvas.className = "kui-shader-canvas";
2494
+ this.canvas.style.cssText = `position:fixed;top:0;left:0;pointer-events:none;z-index:${readShaderZIndex(this.document, this.window)};`;
2495
+ this.gl = this.canvas.getContext ? this.canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true }) : null;
2496
+ if (!this.gl) return false;
2497
+ syncCanvasDimensions(this.canvas, this.window, getScissorEnv(this.window).dpr);
2498
+ this.document.body.appendChild(this.canvas);
2499
+ if (!this.initQuad()) return this.abandonInit();
2500
+ this.initPrograms();
2501
+ this.bindEvents();
2502
+ this.startLoop();
2503
+ return true;
2504
+ }
2505
+ /**
2506
+ * Undo a half-built `init()` and report it as a failure.
2507
+ *
2508
+ * Not `destroy()`: nothing has acquired this renderer yet, so there is no teardown to broadcast,
2509
+ * and marking it `isDestroyed` would retire an object that is free to try again the next time
2510
+ * something acquires it. Under transient memory pressure that retry is the whole point — the
2511
+ * alternative is a permanently dead renderer for a `createBuffer` that would succeed a second
2512
+ * later. The canvas goes because it is already in the document by this point; leaving it would
2513
+ * stack one invisible full-viewport canvas per attempt.
2514
+ */
2515
+ abandonInit() {
2516
+ disposeGLResources(this.gl, this.quadBuffer, this.programs);
2517
+ this.canvas?.remove();
2518
+ this.canvas = null;
2519
+ this.gl = null;
2520
+ this.quadBuffer = null;
2521
+ this.programs = {};
2522
+ return false;
2523
+ }
2524
+ /**
2525
+ * Allocate and fill the one static buffer every draw in this module reads its vertices from.
2526
+ *
2527
+ * Returns whether it worked, and the caller has to care. `gl.createBuffer()` returns `null` under
2528
+ * memory pressure rather than throwing, and so does nothing else downstream: `vertexAttribPointer`
2529
+ * and `drawArrays` against a null binding only *set* a GL error, and `drawElementQuad` used to
2530
+ * return `true` regardless. The visible result was the worst possible one — every affected
2531
+ * element's `opacity` driven to 0 by `hideBehindRenderer` with nothing drawn over it, so the page
2532
+ * lost its images rather than losing an effect. A renderer that cannot allocate this is not a
2533
+ * renderer, so it fails here instead.
2534
+ *
2535
+ * `bufferData` is guarded too, and only for a host that throws rather than raising `OUT_OF_MEMORY`
2536
+ * through the error queue. Reading the queue is deliberately not done: `getError` is a
2537
+ * synchronous round-trip to the GPU process, and the allocation failure it would catch is already
2538
+ * caught by the null above.
2539
+ */
2540
+ initQuad() {
2541
+ const gl = this.gl;
2542
+ if (!gl) return false;
2543
+ this.quadBuffer = gl.createBuffer();
2544
+ if (!this.quadBuffer) return false;
2545
+ try {
2546
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
2547
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
2548
+ } catch {
2549
+ if (gl.deleteBuffer) gl.deleteBuffer(this.quadBuffer);
2550
+ this.quadBuffer = null;
2551
+ return false;
2552
+ }
2553
+ return true;
2554
+ }
2555
+ initPrograms() {
2556
+ const gl = this.gl;
2557
+ if (!gl) return;
2558
+ this.programs = {};
2559
+ for (const [modes, fs] of PROGRAM_SOURCES) {
2560
+ const prog = createProgram(gl, FULLSCREEN_QUAD_VS, fs);
2561
+ if (!prog) continue;
2562
+ const locs = extractLocations(gl, prog);
2563
+ for (const mode of modes) this.programs[mode] = locs;
2564
+ }
2565
+ }
2566
+ bindEvents() {
2567
+ this.onPointer = (e) => {
2568
+ this.mouse.x = e.clientX;
2569
+ this.mouse.y = e.clientY;
2570
+ this.hasPointer = true;
2571
+ };
2572
+ this.window?.addEventListener?.("pointermove", this.onPointer, { passive: true });
2573
+ if (this.canvas) {
2574
+ this.onContextLost = (e) => {
2575
+ if (e && typeof e.preventDefault === "function") e.preventDefault();
2576
+ this.isContextLost = true;
2577
+ this.contextGeneration++;
2578
+ this.stopLoop();
2579
+ for (const cb of this.contextCallbacks.values()) invokeIsolated(() => cb.onLost());
2580
+ };
2581
+ this.onContextRestored = () => {
2582
+ this.isContextLost = false;
2583
+ const ok = this.initQuad();
2584
+ this.initPrograms();
2585
+ for (const cb of this.contextCallbacks.values()) invokeIsolated(() => cb.onRestored());
2586
+ if (ok) this.startLoop();
2587
+ };
2588
+ this.canvas.addEventListener("webglcontextlost", this.onContextLost, false);
2589
+ this.canvas.addEventListener("webglcontextrestored", this.onContextRestored, false);
2590
+ }
2591
+ }
2592
+ createTexture(source) {
2593
+ return this.gl ? createGLTexture(this.gl, source) : null;
2594
+ }
2595
+ register(id, fn, callbacks, readInputs) {
2596
+ this.drawCalls.set(id, fn);
2597
+ if (callbacks) this.contextCallbacks.set(id, callbacks);
2598
+ if (readInputs) this.inputReaders.set(id, readInputs);
2599
+ if (!this.isContextLost) this.startLoop();
2600
+ }
2601
+ unregister(id) {
2602
+ this.drawCalls.delete(id);
2603
+ this.contextCallbacks.delete(id);
2604
+ this.inputReaders.delete(id);
2605
+ }
2606
+ startLoop() {
2607
+ if (this.rafId || this.isContextLost) return;
2608
+ const tick = (ts) => {
2609
+ this.renderFrame(ts * 1e-3);
2610
+ if (this.drawCalls.size > 0 && !this.isContextLost) {
2611
+ this.rafId = this.raf(tick);
2612
+ } else {
2613
+ this.rafId = null;
2614
+ }
2615
+ };
2616
+ this.rafId = this.raf(tick);
2617
+ }
2618
+ stopLoop() {
2619
+ if (this.rafId) {
2620
+ this.caf(this.rafId);
2621
+ this.rafId = null;
2622
+ }
2623
+ }
2624
+ renderFrame(timeSeconds) {
2625
+ const gl = this.gl;
2626
+ if (!gl || !this.canvas || this.isContextLost) return;
2627
+ const dpr = Math.min(this.window?.devicePixelRatio ?? 1, 2);
2628
+ syncCanvasDimensions(this.canvas, this.window, dpr);
2629
+ gl.disable(gl.SCISSOR_TEST);
2630
+ gl.clearColor(0, 0, 0, 0);
2631
+ gl.clear(gl.COLOR_BUFFER_BIT);
2632
+ for (const readInputs of this.inputReaders.values()) invokeIsolated(readInputs);
2633
+ this.runDrawCalls(timeSeconds);
2634
+ }
2635
+ /**
2636
+ * Every registered instance's draw, in registration order, each one isolated from its neighbours.
2637
+ *
2638
+ * The snapshot is here because a draw call may unregister itself — the `catch` below does — and
2639
+ * mutating `drawCalls` under a live iterator would skip its neighbour. But a snapshot of the
2640
+ * *entries* must not become a snapshot of the *context*, which is why the `gl` is re-read per
2641
+ * iteration rather than taken from `renderFrame`'s. `destroy()` nulls `gl` and deletes the
2642
+ * programs, so a draw call that dropped the last reference mid-frame left every remaining entry
2643
+ * in the snapshot drawing through a disposed context, on a renderer already `isDestroyed` with
2644
+ * its `drawCalls` cleared. The same re-read covers a context lost mid-frame, where `gl` survives
2645
+ * but the programs and buffers do not.
2646
+ *
2647
+ * @param timeSeconds - This frame's timestamp, passed to every draw call.
2648
+ * @complexity O(n) in registered instances.
2649
+ */
2650
+ runDrawCalls(timeSeconds) {
2651
+ for (const [id, drawCall] of Array.from(this.drawCalls.entries())) {
2652
+ const live = this.gl;
2653
+ if (!live || this.isContextLost) return;
2654
+ try {
2655
+ drawCall(live, timeSeconds);
2656
+ } catch {
2657
+ const callbacks = this.contextCallbacks.get(id);
2658
+ if (callbacks) invokeIsolated(() => callbacks.onLost());
2659
+ this.unregister(id);
2660
+ }
2661
+ }
2662
+ }
2663
+ destroy() {
2664
+ if (this.isDestroyed) return;
2665
+ this.isDestroyed = true;
2666
+ this.stopLoop();
2667
+ if (this.window?.removeEventListener && this.onPointer) {
2668
+ this.window.removeEventListener("pointermove", this.onPointer);
2669
+ }
2670
+ if (this.canvas) {
2671
+ if (this.onContextLost) this.canvas.removeEventListener("webglcontextlost", this.onContextLost);
2672
+ if (this.onContextRestored) this.canvas.removeEventListener("webglcontextrestored", this.onContextRestored);
2673
+ this.canvas.remove();
2674
+ this.canvas = null;
2675
+ }
2676
+ disposeGLResources(this.gl, this.quadBuffer, this.programs);
2677
+ this.gl = null;
2678
+ this.quadBuffer = null;
2679
+ this.isContextLost = false;
2680
+ this.drawCalls.clear();
2681
+ this.contextCallbacks.clear();
2682
+ this.inputReaders.clear();
2683
+ this.programs = {};
2684
+ this.refCount = 0;
2685
+ if (shaderRenderers.get(this.mapKey) === this) shaderRenderers.delete(this.mapKey);
2686
+ }
2687
+ };
2688
+ var shaderRenderers = /* @__PURE__ */ new WeakMap();
2689
+ var fallbackShaderKey = {};
2690
+ function getSharedShaderRenderer(env) {
2691
+ const resolved = resolveEnv(null, env);
2692
+ const key = resolved.document ?? resolved.window ?? fallbackShaderKey;
2693
+ let renderer = shaderRenderers.get(key);
2694
+ if (!renderer || renderer.isDestroyed) {
2695
+ renderer = new SharedShaderRenderer(env);
2696
+ shaderRenderers.set(key, renderer);
2697
+ }
2698
+ return renderer;
2699
+ }
2700
+ function createRendererRef(env) {
2701
+ let current = getSharedShaderRenderer(env);
2702
+ return {
2703
+ get() {
2704
+ if (current.isDestroyed) current = getSharedShaderRenderer(env);
2705
+ return current;
2706
+ }
2707
+ };
2708
+ }
2709
+ function initTextureHolder(getSource, host) {
2710
+ let tex = null;
2711
+ let owner = null;
2712
+ let generation = -1;
2713
+ const dropIfStale = (renderer) => {
2714
+ if (tex && (owner !== renderer || generation !== renderer.contextGeneration)) tex = null;
2715
+ };
2716
+ return {
2717
+ get() {
2718
+ const renderer = host.renderer;
2719
+ dropIfStale(renderer);
2720
+ if (!tex && !renderer.isContextLost) {
2721
+ const src = getSource();
2722
+ if (src) {
2723
+ tex = renderer.createTexture(src);
2724
+ owner = renderer;
2725
+ generation = renderer.contextGeneration;
2726
+ }
2727
+ }
2728
+ return tex;
2729
+ },
2730
+ peek() {
2731
+ dropIfStale(host.renderer);
2732
+ return tex;
2733
+ },
2734
+ reset() {
2735
+ tex = null;
2736
+ }
2737
+ };
2738
+ }
2739
+ function createShaderTextureHolders(htmlEl, options, host) {
2740
+ const isImg = (t) => t instanceof HTMLImageElement && t.complete && t.naturalWidth > 0;
2741
+ const texHolder = initTextureHolder(() => isImg(htmlEl) ? htmlEl : null, host);
2742
+ const toHolder = initTextureHolder(() => {
2743
+ const doc = host.renderer.document;
2744
+ if (!options.to || !doc) return null;
2745
+ try {
2746
+ const toEl = doc.querySelector?.(options.to);
2747
+ return isImg(toEl) ? toEl : null;
2748
+ } catch {
2749
+ return null;
2750
+ }
2751
+ }, host);
2752
+ return { texHolder, toHolder };
2753
+ }
2754
+ function cleanupShaderTextures(texHolder, toHolder, renderer) {
2755
+ const tex = texHolder.peek(), toTex = toHolder.peek();
2756
+ if (tex && renderer.gl) renderer.gl.deleteTexture(tex);
2757
+ if (toTex && toTex !== tex && renderer.gl) renderer.gl.deleteTexture(toTex);
2758
+ texHolder.reset();
2759
+ toHolder.reset();
2760
+ renderer.release();
2761
+ }
2762
+ function hideBehindRenderer(el, state) {
2763
+ if (state.hiddenByRenderer) return;
2764
+ const style = styleOf(state.ledgers, el);
2765
+ if (!style) return;
2766
+ style.set("opacity", "0");
2767
+ state.hiddenByRenderer = true;
2768
+ }
2769
+ function restoreInstanceOpacity(el, state) {
2770
+ if (!state.hiddenByRenderer) return;
2771
+ state.hiddenByRenderer = false;
2772
+ const style = styleOf(state.ledgers, el);
2773
+ if (style) style.set("opacity", style.peek("opacity") ?? "");
2774
+ }
2775
+ var SHADER_TEXTURE_GRACE_MS = 5e3;
2776
+ function acquireRenderer(info, held) {
2777
+ if (held && !info.renderer.isDestroyed) return true;
2778
+ const live = info.ref.get();
2779
+ if (!live.acquire()) return false;
2780
+ info.renderer = live;
2781
+ info.tex.reset();
2782
+ info.to.reset();
2783
+ return true;
2784
+ }
2785
+ function instanceProgress(info) {
2786
+ const authored = info.opt.progress;
2787
+ if (authored !== void 0 && authored >= 0) return authored;
2788
+ return info.opt.scrubsFromScroll ? readElementProgress(info.el) : -1;
2789
+ }
2790
+ function createShaderDrawCall(info, state, isGenerative, onFailure) {
2791
+ return (_gl, time) => {
2792
+ let drew = false;
2793
+ try {
2794
+ const texture = info.tex.get();
2795
+ drew = (!!texture || isGenerative) && drawElementQuad(info.renderer, info.el, {
2796
+ ...info.opt,
2797
+ texture,
2798
+ toTexture: info.to.get(),
2799
+ time,
2800
+ progress: state.progress,
2801
+ audio: state.audio,
2802
+ geometry: state.geometry
2803
+ });
2804
+ } catch {
2805
+ onFailure();
2806
+ restoreInstanceOpacity(info.el, state);
2807
+ info.renderer.unregister(info.id);
2808
+ return;
2809
+ }
2810
+ if (drew && !isGenerative) hideBehindRenderer(info.el, state);
2811
+ else if (!isGenerative) restoreInstanceOpacity(info.el, state);
2812
+ };
2813
+ }
2814
+ function createGraceTransition(grace, deactivate, isAcquired) {
2815
+ return () => {
2816
+ deactivate();
2817
+ if (isAcquired()) grace.arm();
2818
+ };
2819
+ }
2820
+ function createGraceTimer(info, onExpire, isBusy) {
2821
+ let handle = null;
2822
+ let generation = 0;
2823
+ const cancel = () => {
2824
+ generation += 1;
2825
+ const cancelled = handle;
2826
+ handle = null;
2827
+ if (cancelled !== null) info.clearTimer(cancelled);
2828
+ };
2829
+ return {
2830
+ cancel,
2831
+ arm() {
2832
+ cancel();
2833
+ const armedGeneration = generation;
2834
+ const armedHandle = info.setTimer(() => {
2835
+ if (generation !== armedGeneration) return;
2836
+ generation += 1;
2837
+ handle = null;
2838
+ if (!isBusy()) onExpire();
2839
+ }, SHADER_TEXTURE_GRACE_MS);
2840
+ if (generation === armedGeneration) handle = armedHandle;
2841
+ }
2842
+ };
2843
+ }
2844
+ function createShaderInstance(info) {
2845
+ const state = {
2846
+ // Shared per element with every other writer, advanced or core — see `createAdvancedLedgers`.
2847
+ // A private `createLedgerSet` here captured whatever a CSS effect on the same element had
2848
+ // already written to `opacity` as the author's own value, and handed it back on teardown.
2849
+ ledgers: createAdvancedLedgers(info.el),
2850
+ hiddenByRenderer: false,
2851
+ progress: -1,
2852
+ audio: 0,
2853
+ geometry: null,
2854
+ hostAlpha: 1
2855
+ };
2856
+ let isActive = false, isAcquired = false;
2857
+ const isGenerative = GENERATIVE_MODES.has(info.opt.mode);
2858
+ const readInputs = () => {
2859
+ state.progress = instanceProgress(info);
2860
+ state.audio = info.opt.audioBand ? readAudioBand(info.el, info.opt.audioBand) : 0;
2861
+ const cached = state.hiddenByRenderer ? state.hostAlpha : void 0;
2862
+ state.geometry = measureElementGeometry(info.el, info.renderer.window, isGenerative, cached);
2863
+ if (state.geometry?.hostAlpha !== void 0) state.hostAlpha = state.geometry.hostAlpha;
2864
+ };
2865
+ const restoreState = () => {
2866
+ if (!isActive) return;
2867
+ isActive = false;
2868
+ info.renderer.unregister(info.id);
2869
+ restoreInstanceOpacity(info.el, state);
2870
+ };
2871
+ const releaseResources = () => {
2872
+ if (!isAcquired) return;
2873
+ isAcquired = false;
2874
+ cleanupShaderTextures(info.tex, info.to, info.renderer);
2875
+ };
2876
+ const grace = createGraceTimer(info, releaseResources, () => isActive);
2877
+ const enterGrace = createGraceTransition(grace, () => {
2878
+ isActive = false;
2879
+ }, () => isAcquired);
2880
+ const drawCall = createShaderDrawCall(info, state, isGenerative, enterGrace);
2881
+ const suspend = () => {
2882
+ if (!isActive) return;
2883
+ try {
2884
+ restoreState();
2885
+ } finally {
2886
+ enterGrace();
2887
+ }
2888
+ };
2889
+ return createEffectInstance({
2890
+ continuous: true,
2891
+ activate() {
2892
+ if (isActive) return;
2893
+ if (!acquireRenderer(info, isAcquired)) return;
2894
+ grace.cancel();
2895
+ isAcquired = true;
2896
+ isActive = true;
2897
+ info.renderer.register(info.id, drawCall, {
2898
+ onLost() {
2899
+ info.tex.reset();
2900
+ info.to.reset();
2901
+ restoreInstanceOpacity(info.el, state);
2902
+ },
2903
+ onRestored() {
2904
+ }
2905
+ }, readInputs);
2906
+ info.renderer.startLoop();
2907
+ },
2908
+ // Unregister and un-hide, then hold the textures and the renderer reference for
2909
+ // {@link SHADER_TEXTURE_GRACE_MS} before giving them up.
2910
+ //
2911
+ // This is the grace-timer half of the trade `e49d517` set up and left open, taken deliberately
2912
+ // rather than closed as a tidy-up. That commit chose to retain until `destroy()` because
2913
+ // `release()` tears down the canvas, the context and all six linked programs, so dropping
2914
+ // `refCount` to zero on every hover-out makes the next hover pay a rebuild and six recompiles —
2915
+ // a stall worse, on a hover effect, than holding a texture roughly the size of the decoded
2916
+ // image the browser already has. It named a grace timer as the way to get the memory back
2917
+ // without the stall, and this is it. A give-back inside the window costs nothing: the instance
2918
+ // never let go, so `acquireRenderer` short-circuits and the texture holders still have their
2919
+ // handles. Past it the memory goes back, and a later `activate()` rebuilds against a live
2920
+ // renderer — `RendererRef.get()` re-resolves once the old one reports `isDestroyed`.
2921
+ //
2922
+ // A later audit read the original retention as an oversight and an agent "fixed" it; that was
2923
+ // reverted in `eccb8e5`, because releasing on *every* give-back with no window is the stall,
2924
+ // not the fix. The window is the whole difference.
2925
+ cancel: suspend,
2926
+ finish: suspend,
2927
+ destroy() {
2928
+ restoreState();
2929
+ grace.cancel();
2930
+ releaseResources();
2931
+ state.ledgers.restore();
2932
+ }
2933
+ });
2934
+ }
2935
+ var STATIC_BAKE_MAX = 640;
2936
+ function bakeExtent(rect, canvas, dpr) {
2937
+ if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) return null;
2938
+ if (rect.width <= 0 || rect.height <= 0) return null;
2939
+ const longest = Math.max(rect.width, rect.height) * dpr;
2940
+ const budget = Math.min(STATIC_BAKE_MAX, canvas.width, canvas.height);
2941
+ const factor = longest > budget ? budget / longest : dpr;
2942
+ const w = Math.max(1, Math.round(rect.width * factor));
2943
+ const h = Math.max(1, Math.round(rect.height * factor));
2944
+ return w <= canvas.width && h <= canvas.height ? [w, h] : null;
2945
+ }
2946
+ function readCanvasCorner(canvas, w, h, createCanvas) {
2947
+ const out = createCanvas();
2948
+ const out2d = out?.getContext?.("2d");
2949
+ if (!out || !out2d) return null;
2950
+ out.width = w;
2951
+ out.height = h;
2952
+ try {
2953
+ out2d.drawImage(canvas, 0, 0, w, h, 0, 0, w, h);
2954
+ return out.toDataURL("image/png");
2955
+ } catch {
2956
+ return null;
2957
+ }
2958
+ }
2959
+ function bakeStaticFrame(renderer, el, options, createCanvas) {
2960
+ const gl = renderer.gl;
2961
+ const canvas = renderer.canvas;
2962
+ if (!gl || !canvas || typeof el.getBoundingClientRect !== "function") return null;
2963
+ const dpr = getScissorEnv(renderer.window).dpr;
2964
+ const extent = bakeExtent(el.getBoundingClientRect(), canvas, dpr);
2965
+ if (!extent) return null;
2966
+ const [w, h] = extent;
2967
+ const box = { left: 0, top: 0, right: w / dpr, bottom: h / dpr };
2968
+ const drew = drawElementQuad(renderer, el, {
2969
+ ...options,
2970
+ time: 0,
2971
+ audio: 0,
2972
+ texture: null,
2973
+ geometry: { border: box, paint: box, clip: box, radii: [0, 0, 0, 0, 0, 0, 0, 0], alpha: 1 }
2974
+ });
2975
+ if (!drew) return null;
2976
+ const url = readCanvasCorner(canvas, w, h, createCanvas);
2977
+ gl.enable(gl.SCISSOR_TEST);
2978
+ gl.scissor(0, canvas.height - h, w, h);
2979
+ gl.clearColor(0, 0, 0, 0);
2980
+ gl.clear(gl.COLOR_BUFFER_BIT);
2981
+ return url;
2982
+ }
2983
+ function prepareReducedMotion(el, options, ctx) {
2984
+ if (!GENERATIVE_MODES.has(options.mode)) return createInertInstance();
2985
+ const hostDoc = el?.ownerDocument;
2986
+ const env = resolveEnv(ctx, hostDoc ? { document: hostDoc, window: hostDoc.defaultView } : void 0);
2987
+ const renderer = getSharedShaderRenderer(env);
2988
+ if (!renderer.acquire()) return createInertInstance();
2989
+ const ledgers = createAdvancedLedgers(el);
2990
+ nextShaderId += 1;
2991
+ const id = `kui-shader-static-${nextShaderId}`;
2992
+ let released = false;
2993
+ const release = () => {
2994
+ if (released) return;
2995
+ released = true;
2996
+ renderer.unregister(id);
2997
+ renderer.release();
2998
+ };
2999
+ renderer.register(id, () => {
3000
+ const url = bakeStaticFrame(renderer, el, options, env.createCanvas);
3001
+ if (!url) return;
3002
+ const style = styleOf(ledgers, el);
3003
+ if (style) {
3004
+ style.set("background-image", `url("${url}")`);
3005
+ style.set("background-size", "100% 100%");
3006
+ style.set("background-repeat", "no-repeat");
3007
+ }
3008
+ release();
3009
+ });
3010
+ renderer.startLoop();
3011
+ return createInertInstance(() => {
3012
+ release();
3013
+ ledgers.restore();
3014
+ });
3015
+ }
3016
+ function prepareShaders(el, params, ctx) {
3017
+ const hostDoc = el?.ownerDocument;
3018
+ const resolvedEnv = resolveEnv(ctx, hostDoc ? { document: hostDoc, window: hostDoc.defaultView } : void 0);
3019
+ const options = extractShaderOptions(params, resolvedEnv.createCanvas);
3020
+ if (!SHADER_MODES.includes(options.mode)) return createInertInstance();
3021
+ if (isReducedMotion(ctx)) return prepareReducedMotion(el, options, ctx);
3022
+ const ref = createRendererRef(resolvedEnv);
3023
+ nextShaderId += 1;
3024
+ const id = `kui-shader-instance-${nextShaderId}`;
3025
+ const info = {
3026
+ id,
3027
+ el,
3028
+ opt: options,
3029
+ renderer: ref.get(),
3030
+ ref,
3031
+ setTimer: resolvedEnv.setTimer,
3032
+ clearTimer: resolvedEnv.clearTimer
3033
+ };
3034
+ const holders = createShaderTextureHolders(el, options, info);
3035
+ info.tex = holders.texHolder;
3036
+ info.to = holders.toHolder;
3037
+ return createShaderInstance(info);
3038
+ }
3039
+ var SHADER_PARAMETERS = {
3040
+ mode: { type: "keyword", default: "displace", keywords: [...SHADER_MODES], cssProperty: "--kui-shader-mode" },
3041
+ strength: { type: "number", default: "0.5", minimum: 0, maximum: 10, cssProperty: "--kui-shader-strength" },
3042
+ speed: { type: "number", default: "1.0", minimum: 0, maximum: 10, cssProperty: "--kui-shader-speed" },
3043
+ frequency: { type: "number", default: "10.0", minimum: 0.1, maximum: 50, cssProperty: "--kui-shader-frequency" },
3044
+ chromatic: { type: "number", default: "0.0", minimum: 0, maximum: 1, cssProperty: "--kui-shader-chromatic" },
3045
+ /**
3046
+ * How far the colour ramp is wrapped back on itself — the shimmer count.
3047
+ *
3048
+ * Ranged 0..8 rather than the 0..1 it carried before `mode: gradient` existed. The old bound was
3049
+ * right for a mix amount and wrong for a wrap count, and it cost nothing to police because **no
3050
+ * fragment program declared `u_iridescence` at all** — the value was extracted and uploaded to a
3051
+ * location that was `null` in all five programs. It reaches a shader for the first time here.
3052
+ */
3053
+ iridescence: { type: "number", default: "0.0", minimum: 0, maximum: 8, cssProperty: "--kui-shader-iridescence" },
3054
+ tint: { type: "color", default: "#ffffff", cssProperty: "--kui-shader-tint" },
3055
+ /*
3056
+ * `color1`/`color2` keep their original meaning — the duotone pair `mode: displace` remaps a
3057
+ * photo through — and `mode: gradient` reads the same two as the first stops of its palette.
3058
+ * `color3`..`color5` extend that palette without touching the duotone contract.
3059
+ *
3060
+ * Five separate `color` parameters rather than one comma-separated list: the attribute grammar
3061
+ * splits effect segments on top-level commas, so a list would have to be quoted, and the only
3062
+ * type that could carry it (`text`) validates nothing — a mistyped stop would silently become
3063
+ * white through `parseColor`'s fallback, which is the worst failure a colour input can have.
3064
+ */
3065
+ color1: { type: "color", default: "", cssProperty: "--kui-shader-color1" },
3066
+ color2: { type: "color", default: "", cssProperty: "--kui-shader-color2" },
3067
+ color3: { type: "color", default: "", cssProperty: "--kui-shader-color3" },
3068
+ color4: { type: "color", default: "", cssProperty: "--kui-shader-color4" },
3069
+ color5: { type: "color", default: "", cssProperty: "--kui-shader-color5" },
3070
+ /*
3071
+ * The generative field's own controls. Every one of these is read by this module's JavaScript and
3072
+ * by no stylesheet, so — as `ParamSpecBase.cssProperty` warns — setting the custom property
3073
+ * directly does nothing at all. Widening one means widening it here.
3074
+ */
3075
+ /** Which pattern. The same seed gives the same image on every load, which is the point of it. */
3076
+ seed: { type: "number", default: "0", minimum: 0, maximum: 9999, integer: true, cssProperty: "--kui-shader-seed" },
3077
+ /** How zoomed the field is. Larger means smaller, more numerous shapes. */
3078
+ scale: { type: "number", default: "1", minimum: 0.05, maximum: 20, cssProperty: "--kui-shader-scale" },
3079
+ /**
3080
+ * How many octaves of detail ride on the base shapes.
3081
+ *
3082
+ * Named `detail` rather than the reference tools' "definition" — the plainer word for the same
3083
+ * idea. Capped at 6, which is `KUI_MAX_OCTAVES` in the shader; a seventh would be accepted here
3084
+ * and silently ignored there.
3085
+ */
3086
+ detail: { type: "number", default: "3", minimum: 1, maximum: 6, integer: true, cssProperty: "--kui-shader-detail" },
3087
+ /** How far a second field distorts the first. The difference between contour rings and liquid. */
3088
+ warp: { type: "number", default: "0", minimum: 0, maximum: 2, cssProperty: "--kui-shader-warp" },
3089
+ /**
3090
+ * How much of `fluid`, `liquid`, `particles` and `morph`'s motion comes from real noise.
3091
+ *
3092
+ * Those four shipped with a single trigonometric term standing in for noise, which bands and
3093
+ * repeats visibly. This crossfades that term into the same `kuiFbm` field `mode: gradient`
3094
+ * generates from — `0` is the shipped picture exactly, `1` is the field on its own, and the
3095
+ * values between are a genuine dial rather than a rounded switch.
3096
+ *
3097
+ * **It is off by default and must stay that way.** Four modes are already in use; turning this
3098
+ * on for them without being asked would restyle live pages. See `FILTER_NOISE_GLSL` in
3099
+ * `glsl.ts` for why it is a parameter of its own rather than `warp` doing double duty.
3100
+ *
3101
+ * Above `0` it is also what gives `seed`, `scale`, `warp` and `detail` a meaning on these four
3102
+ * modes; below it they are read and uploaded but nothing samples them. `mode: displace`'s ripple
3103
+ * is a deliberate radial wave rather than fake noise, and `gradient`/`logo` are already nothing
3104
+ * but the noise core, so all three ignore this.
3105
+ */
3106
+ noise: { type: "number", default: "0", minimum: 0, maximum: 1, cssProperty: "--kui-shader-noise" },
3107
+ /** Posterise the ramp into this many steps. `0` is off, as with `chromatic` and `iridescence`. */
3108
+ bands: { type: "number", default: "0", minimum: 0, maximum: 32, integer: true, cssProperty: "--kui-shader-bands" },
3109
+ /** Film grain amount. */
3110
+ grain: { type: "number", default: "0", minimum: 0, maximum: 1, cssProperty: "--kui-shader-grain" },
3111
+ /**
3112
+ * Hue rotation, as a real angle (`hue: 30deg`, `hue: 0.25turn`).
3113
+ *
3114
+ * An `angle` rather than a normalised scalar, matching `carousel`'s `tilt:` and this repository's
3115
+ * standing rejection of normalised ranges where a CSS unit exists. A bare `hue: 30` is accepted
3116
+ * too — `core/params.ts` normalises it to `30deg`.
3117
+ */
3118
+ hue: { type: "angle", default: "0deg", cssProperty: "--kui-shader-hue" },
3119
+ /**
3120
+ * Which part of `mode: logo`'s host image is the mark. Ignored by every other mode.
3121
+ *
3122
+ * `alpha` for the usual transparent-ground SVG or PNG. `luma` and `luma-invert` are for a format
3123
+ * that has no alpha to read — a JPEG under `alpha` gives an all-opaque stencil and fills the
3124
+ * whole rectangle. `luma` takes the bright pixels as the mark, `luma-invert` the dark ones; both
3125
+ * directions are spelled out because guessing produces a perfect negative of the mark, which
3126
+ * looks deliberate and is exactly wrong.
3127
+ */
3128
+ mask: { type: "keyword", default: "alpha", keywords: ["alpha", "luma", "luma-invert"], cssProperty: "--kui-shader-mask" },
3129
+ /**
3130
+ * How `mode: gradient` and `mode: logo` answer the pointer. Ignored by the five filter modes.
3131
+ *
3132
+ * Being *responsive to the pointer* is the one thing a shader background does that a video of a
3133
+ * shader cannot, and these two modes — the two built to stand in for a video background — were
3134
+ * the only ones in the tier with no pointer response at all. `displace` and `fluid` have read
3135
+ * `u_mouse` since they were written.
3136
+ *
3137
+ * - `stir` — the cursor drags the colour flow around it, like stirring thick paint. It moves the
3138
+ * field's *sample point*, so on `mode: logo` the mark's edges do not move: `glyphMask()` reads
3139
+ * the host image at the undistorted coordinate and multiplies the finished colour, which means
3140
+ * the fill swirls inside a stencil that stays exactly where it was.
3141
+ * - `light` — the cursor is a torch held over the surface. Nothing moves; the colour under the
3142
+ * pointer is multiplied up, which raises its saturation rather than washing it toward white.
3143
+ * - `both` — the two together. They compose cleanly because they act at different stages, one on
3144
+ * the coordinate and one on the colour that coordinate produced.
3145
+ *
3146
+ * `none` by default, and `none` means the shader never reads `u_mouse` at all — not that it
3147
+ * reads it and multiplies by zero. Every page already running these modes renders what it always
3148
+ * rendered, byte for byte.
3149
+ *
3150
+ * With no pointer yet — the state of every page on load, every phone, and every screenshot —
3151
+ * `computeMousePos` answers the element's centre, so `light` rests as a centred glow and `stir`
3152
+ * as a centred swirl. Both are compositions someone might have authored on purpose, which is the
3153
+ * bar a resting state has to clear.
3154
+ */
3155
+ hover: { type: "keyword", default: "none", keywords: ["none", "stir", "light", "both"], cssProperty: "--kui-shader-hover" },
3156
+ /**
3157
+ * A colour painted behind the generative field, inside the element's own shape.
3158
+ *
3159
+ * What it is for is `mode: logo`: outside the mark the field has no coverage, so this is the
3160
+ * plate the mark sits on instead of the page showing through.
3161
+ *
3162
+ * On a bare `mode: gradient` it is **invisible**, and that is arithmetic rather than an
3163
+ * oversight — the field is opaque, so there is nothing behind it to see. It becomes visible
3164
+ * there through a `tint` whose alpha is below 1, which is how a field is made to sit *on* a page
3165
+ * colour rather than replace it. Unset by default, and read through the raw parameter string
3166
+ * rather than `parseColor`, whose empty-input fallback is opaque white.
3167
+ */
3168
+ backdrop: { type: "color", default: "", cssProperty: "--kui-shader-backdrop" },
3169
+ /**
3170
+ * The generative field's orientation: the axis it runs along.
3171
+ *
3172
+ * `transform: rotate()`'s convention — clockwise-positive, `0deg` pointing right — because that
3173
+ * is the one an author already has. It turns the field's whole domain, so it is visible at the
3174
+ * library defaults rather than only in company with another parameter, and it is the axis
3175
+ * `motion: drift` travels along. One idea with two consequences, not two behaviours: the field
3176
+ * has an axis, this names it, and `drift` slides along the thing it named.
3177
+ *
3178
+ * An `angle` rather than a normalised scalar, matching `hue` above and this repository's
3179
+ * standing rejection of 0..1 ranges where a CSS unit exists. A bare `angle: 30` is accepted —
3180
+ * `core/params.ts` normalises it to `30deg`.
3181
+ */
3182
+ angle: { type: "angle", default: "0deg", cssProperty: "--kui-shader-angle" },
3183
+ /**
3184
+ * How the generative field moves. Ignored by the five filter modes.
3185
+ *
3186
+ * - `evolve` — the field changes *in place*. Time is the noise function's third axis rather than
3187
+ * a translation of a 2D field, which is why it reads as the pattern genuinely evolving instead
3188
+ * of sliding past. See `NOISE_GLSL` in `glsl.ts`.
3189
+ * - `drift` — it also travels along `angle`, at a rate proportional to `scale` so the visual
3190
+ * speed is the same at any zoom.
3191
+ * - `swirl` — it also turns about the element's centre.
3192
+ *
3193
+ * **`evolve` exists so that the default can be inert.** `drift` would have been the natural
3194
+ * default name, and it is what this parameter's brief asked for — but `drift` is a new look, and
3195
+ * defaulting to it would restyle every page already running `gradient` or `logo` on the next
3196
+ * release. That is the same trap `noise:` above is written around, and the reason `hover:`
3197
+ * defaults to `none`. So the shipped behaviour keeps the default and gets an accurate name, and
3198
+ * the two new ones are opt-in. `drift` and `swirl` add to `evolve` rather than replacing it, so
3199
+ * a travelling field is still changing as it goes rather than one frozen picture towed across
3200
+ * the box.
3201
+ */
3202
+ motion: { type: "keyword", default: "evolve", keywords: ["evolve", "drift", "swirl"], cssProperty: "--kui-shader-motion" },
3203
+ blend: { type: "keyword", default: "normal", keywords: ["normal", "screen", "multiply", "add"], cssProperty: "--kui-shader-blend" },
3204
+ to: { type: "text", default: "", cssProperty: "--kui-shader-to" },
3205
+ /**
3206
+ * A fixed scrub position, 0..1. `-1` is the unauthored sentinel and means "no scrub".
3207
+ *
3208
+ * `--kui-shader-progress`, **not** `--kui-progress`. Every authored parameter reaches
3209
+ * `element.style` as its custom property — `core/compile.ts`'s `buildPlan` runs `resolveParams`
3210
+ * over JS-rendered entries too — so while this pointed at `--kui-progress` an authored
3211
+ * `progress: 0.4` wrote the scroll primitives' own channel onto the element: it raced
3212
+ * `scroll-progress` when the two were composed, and it inherited down to every descendant
3213
+ * shader and pinned timeline underneath. `test/carousel-3d.test.ts` already carries a
3214
+ * regression test that a primitive must not publish `--kui-progress` meaning something else.
3215
+ * Nothing is lost by renaming — this module reads the authored value from the parameter, never
3216
+ * back off the property.
3217
+ */
3218
+ progress: { type: "number", default: "-1", minimum: -1, maximum: 1, cssProperty: "--kui-shader-progress" },
3219
+ /**
3220
+ * Whether this shader is scrubbed by `--kui-progress` — the property `scroll-progress`,
3221
+ * `timeline: pin` and the rest of the scroll family publish.
3222
+ *
3223
+ * Opt-in, and `off` by default, for the same reason `audio:` below is: the value arrives as a
3224
+ * custom property written by some *other* primitive, and `--kui-progress` is an ordinary
3225
+ * inheriting custom property, so a shader that read one unasked changed its own output the
3226
+ * moment an unrelated `scroll-progress` appeared anywhere above it in the tree. That was not a
3227
+ * subtle drift — at the top of a scroll range the published value is `0`, every program
3228
+ * multiplies its effect by it, and the shader rendered an untouched copy of the source image.
3229
+ * Dead, silently, with no way for the author to decline.
3230
+ *
3231
+ * `keyword`, so the list is closed and a typo warns instead of quietly doing nothing.
3232
+ * `--kui-shader-scrub` is a name read by this module's JavaScript and by no stylesheet, so
3233
+ * setting the custom property directly does nothing; the value itself is read from
3234
+ * `--kui-progress`, which is a different contract entirely.
3235
+ */
3236
+ scrub: { type: "keyword", default: "off", keywords: ["off", "scroll"], cssProperty: "--kui-shader-scrub" },
3237
+ /**
3238
+ * Which `audio-source` band drives this shader, if any.
3239
+ *
3240
+ * Opt-in, and `off` by default: the value arrives as a custom property written by some *other*
3241
+ * primitive, so a shader that read one unasked would change its own output the moment an
3242
+ * unrelated `audio-source` appeared anywhere above it in the tree.
3243
+ *
3244
+ * `keyword`, so the list is closed — and `--kui-shader-audio` is a name, read by this module's
3245
+ * JavaScript and by no stylesheet, so setting the custom property in CSS does nothing. The
3246
+ * band values themselves are read from `--kui-audio-*`, which is a different contract entirely
3247
+ * (`audio.ts`'s `AUDIO_BAND_PROPERTIES`).
3248
+ */
3249
+ audio: { type: "keyword", default: "off", keywords: ["off", ...AUDIO_BANDS], cssProperty: "--kui-shader-audio" }
3250
+ };
3251
+ var SHADERS_PRIMITIVE = {
3252
+ id: "shaders",
3253
+ renderer: "javascript",
3254
+ channels: ["opacity"],
3255
+ parameters: SHADER_PARAMETERS,
3256
+ supportedTimelines: ["time", "view", "scroll"],
3257
+ supportedActivations: ["load", "enter", "hover", "click"],
3258
+ defaultActivation: "load",
3259
+ perfClass: "continuous",
3260
+ reducedMotion: "disable",
3261
+ prepare: prepareShaders
3262
+ };
3263
+ var SHADERS_PRESETS = [
3264
+ { name: "shaders", primitive: "shaders" },
3265
+ { name: "shader-displace", primitive: "shaders", params: { mode: "displace" } },
3266
+ { name: "shader-fluid", primitive: "shaders", params: { mode: "fluid" } },
3267
+ { name: "fluid-pointer", primitive: "shaders", params: { mode: "fluid" } },
3268
+ { name: "shader-liquid", primitive: "shaders", params: { mode: "liquid" } },
3269
+ { name: "liquid-distort", primitive: "shaders", params: { mode: "liquid" } },
3270
+ { name: "shader-particles", primitive: "shaders", params: { mode: "particles" } },
3271
+ { name: "particle-field", primitive: "shaders", params: { mode: "particles" } },
3272
+ { name: "shader-morph", primitive: "shaders", params: { mode: "morph" } },
3273
+ { name: "image-morph", primitive: "shaders", params: { mode: "morph" } },
3274
+ /*
3275
+ * The generative family. One fragment program, four sets of defaults — a preset here is a
3276
+ * starting point in the same parameter space, not a separate shader.
3277
+ *
3278
+ * These are the only presets in the catalog that work on an element with no image in it, and
3279
+ * their honest surface is still an element nothing overlaps — but for a different reason than it
3280
+ * used to be. The shared canvas now defaults to `z-index: 1`, so a positioned headline laid over
3281
+ * one of these paints on top of it, where before the headline vanished behind it. Better, and
3282
+ * still not composition: `border-radius`, ancestor clipping and ancestor opacity are reproduced
3283
+ * in the fragment shader, but the replica's place in the stacking order is the *canvas's*, not
3284
+ * the element's. `--kui-shader-z` moves that one canvas and every replica on it together. See
3285
+ * `docs/advanced-modules.md`.
3286
+ */
3287
+ { name: "shader-gradient", primitive: "shaders", params: { mode: "gradient" } },
3288
+ { name: "gradient-liquid", primitive: "shaders", params: { mode: "gradient", warp: "1.1", detail: "4", speed: "0.4" } },
3289
+ { name: "gradient-bands", primitive: "shaders", params: { mode: "gradient", bands: "8", warp: "0.6", speed: "0.3" } },
3290
+ // No palette on purpose: with fewer than two colours set the ramp falls back to its default pair,
3291
+ // and the sheen comes from wrapping it rather than from the stops themselves.
3292
+ { name: "gradient-holo", primitive: "shaders", params: { mode: "gradient", iridescence: "3", chromatic: "0.5", warp: "0.4", speed: "0.5" } },
3293
+ /*
3294
+ * `logo` is the one generative mode with no stacking caveat at all. It replaces an `<img>`
3295
+ * exactly as the five filters do — the host image is the stencil, not the picture — so a mark
3296
+ * painting over the page is what an author wants rather than the limitation above.
3297
+ *
3298
+ * `<img src="mark.svg" data-kui="shader-logo">` and nothing else: the asset already carries the
3299
+ * shape, `object-fit` already decides where it sits, and the element is already the right size.
3300
+ */
3301
+ { name: "shader-logo", primitive: "shaders", params: { mode: "logo" } },
3302
+ { name: "logo-gradient", primitive: "shaders", params: { mode: "logo", warp: "0.8", detail: "4", scale: "1.6", speed: "0.4" } }
3303
+ ];
3304
+ function registerShaders(target) {
3305
+ return registerInto(target, SHADERS_PRIMITIVE, SHADERS_PRESETS, "Shaders");
3306
+ }
3307
+
3308
+ // src/advanced/scenes.ts
3309
+ var NUM = String.raw`-?(?:\d+(?:\.\d+)?|\.\d+)`;
3310
+ var FROM_REGEX = new RegExp(String.raw`\bfrom:(${NUM})`);
3311
+ var TO_REGEX = new RegExp(String.raw`\bto:(${NUM})`);
3312
+ var OPACITY_REGEX = /\bopacity:([0-9.]+)->([0-9.]+)/;
3313
+ var X_REGEX = /\bx:([+-]?[0-9.]+[a-z%]*)->([+-]?[0-9.]+[a-z%]*)/i;
3314
+ var Y_REGEX = /\by:([+-]?[0-9.]+[a-z%]*)->([+-]?[0-9.]+[a-z%]*)/i;
3315
+ var SCALE_REGEX = /\bscale:([0-9.]+)->([0-9.]+)/;
3316
+ function parseWindow(kui) {
3317
+ return [readEnd(FROM_REGEX, kui, 0), readEnd(TO_REGEX, kui, 1)];
3318
+ }
3319
+ function readEnd(pattern, kui, fallback) {
3320
+ const match = pattern.exec(kui);
3321
+ if (!match) return fallback;
3322
+ const value = parseFloat(match[1]);
3323
+ return value >= 0 && value <= 1 ? value : fallback;
3324
+ }
3325
+ function extractUnit(str) {
3326
+ let i = str.length - 1;
3327
+ while (i >= 0 && (str[i] >= "a" && str[i] <= "z" || str[i] >= "A" && str[i] <= "Z" || str[i] === "%")) {
3328
+ i--;
3329
+ }
3330
+ return str.slice(i + 1);
3331
+ }
3332
+ function parseTransitionValue(str) {
3333
+ if (!str) return null;
3334
+ const arrowIdx = str.indexOf("->");
3335
+ if (arrowIdx === -1) return null;
3336
+ const fromStr = str.slice(0, arrowIdx).trim();
3337
+ const toStr = str.slice(arrowIdx + 2).trim();
3338
+ const fromNum = parseFloat(fromStr);
3339
+ const toNum = parseFloat(toStr);
3340
+ if (Number.isNaN(fromNum) || Number.isNaN(toNum)) return null;
3341
+ const unit = extractUnit(fromStr) || extractUnit(toStr);
3342
+ return { from: fromNum, to: toNum, unit };
3343
+ }
3344
+ function parseChildStep(child) {
3345
+ const kui = child?.getAttribute ? child.getAttribute("data-kui") || "" : "";
3346
+ const range = parseWindow(kui);
3347
+ const opMatch = OPACITY_REGEX.exec(kui);
3348
+ const opacity = opMatch ? parseTransitionValue(`${opMatch[1]}->${opMatch[2]}`) : null;
3349
+ const xMatch = X_REGEX.exec(kui);
3350
+ const x = xMatch ? parseTransitionValue(`${xMatch[1]}->${xMatch[2]}`) : null;
3351
+ const yMatch = Y_REGEX.exec(kui);
3352
+ const y = yMatch ? parseTransitionValue(`${yMatch[1]}->${yMatch[2]}`) : null;
3353
+ const scMatch = SCALE_REGEX.exec(kui);
3354
+ const scale = scMatch ? parseTransitionValue(`${scMatch[1]}->${scMatch[2]}`) : null;
3355
+ return { range, opacity, x, y, scale };
3356
+ }
3357
+ function buildStepTransforms(cfg, localT) {
3358
+ const transforms = [];
3359
+ if (cfg.x) {
3360
+ transforms.push(`translateX(${lerp(cfg.x.from, cfg.x.to, localT)}${cfg.x.unit})`);
3361
+ }
3362
+ if (cfg.y) {
3363
+ transforms.push(`translateY(${lerp(cfg.y.from, cfg.y.to, localT)}${cfg.y.unit})`);
3364
+ }
3365
+ if (cfg.scale) {
3366
+ transforms.push(`scale(${lerp(cfg.scale.from, cfg.scale.to, localT)})`);
3367
+ }
3368
+ return transforms.join(" ");
3369
+ }
3370
+ var FALLBACK_VIEWPORT_HEIGHT = 800;
3371
+ var SceneController = class {
3372
+ container;
3373
+ options;
3374
+ env;
3375
+ window;
3376
+ raf;
3377
+ caf;
3378
+ steps = [];
3379
+ progress = 0;
3380
+ rafId = null;
3381
+ isListening = false;
3382
+ timeStart = 0;
3383
+ /**
3384
+ * One ledger per step element, each opened at that step's first write and shared with every
3385
+ * other controller writing to the same element — see `createAdvancedLedgers`.
3386
+ *
3387
+ * Steps are collected during `prepare`, which can be a long way before the scene ever scrolls
3388
+ * into view. Recording opacity and transform *there* meant destroy restored the markup as it
3389
+ * looked at preparation time, discarding anything the author wrote in between.
3390
+ *
3391
+ * Sharing is also what keeps two *legitimate* owners of one element honest. A nested `scene` is
3392
+ * no longer one of them — `prepareScene` scopes its scan with {@link ownedDescendants}, so an
3393
+ * outer scene stops at an inner one instead of claiming its steps — but an element authored as
3394
+ * both a `scene-step` and a `camera-layer` still has two, because those are different kinds of
3395
+ * host, and so does any controller constructed directly rather than through `prepare`.
3396
+ */
3397
+ ledgers;
3398
+ constructor(container, options = {}, env = {}) {
3399
+ this.container = container;
3400
+ this.options = options;
3401
+ this.env = env;
3402
+ const resolved = resolveEnv(null, env);
3403
+ this.window = resolved.window;
3404
+ this.raf = resolved.raf;
3405
+ this.caf = resolved.caf;
3406
+ this.ledgers = createAdvancedLedgers(container);
3407
+ this.onScroll = this.onScroll.bind(this);
3408
+ }
3409
+ addStep(element, config) {
3410
+ this.steps.push({ element, config });
3411
+ }
3412
+ start(onComplete) {
3413
+ if (this.isListening) return;
3414
+ if (!this.window && this.options.progress !== "time") return;
3415
+ this.isListening = true;
3416
+ if (this.options.progress === "time") {
3417
+ this.startTimeLoop(onComplete);
3418
+ } else {
3419
+ if (this.window?.addEventListener) {
3420
+ this.window.addEventListener("scroll", this.onScroll, { passive: true });
3421
+ this.window.addEventListener("resize", this.onScroll, { passive: true });
3422
+ }
3423
+ this.onScroll();
3424
+ }
3425
+ }
3426
+ stop() {
3427
+ if (!this.isListening) return;
3428
+ if (this.window?.removeEventListener) {
3429
+ this.window.removeEventListener("scroll", this.onScroll);
3430
+ this.window.removeEventListener("resize", this.onScroll);
3431
+ }
3432
+ if (this.rafId) this.caf(this.rafId);
3433
+ this.rafId = null;
3434
+ this.isListening = false;
3435
+ }
3436
+ startTimeLoop(onComplete) {
3437
+ const duration = this.options.duration || 1e3;
3438
+ const now = () => this.window?.performance?.now ? this.window.performance.now() : Date.now();
3439
+ this.timeStart = now();
3440
+ const tick = () => {
3441
+ if (!this.isListening) {
3442
+ this.rafId = null;
3443
+ return;
3444
+ }
3445
+ const elapsed = now() - this.timeStart;
3446
+ this.progress = clamp(elapsed / duration, 0, 1);
3447
+ this.updateAll();
3448
+ if (this.progress < 1) {
3449
+ this.rafId = this.raf(tick);
3450
+ return;
3451
+ }
3452
+ this.rafId = null;
3453
+ this.isListening = false;
3454
+ if (onComplete) onComplete();
3455
+ };
3456
+ this.rafId = this.raf(tick);
3457
+ }
3458
+ onScroll() {
3459
+ if (this.rafId) return;
3460
+ this.rafId = this.raf(() => {
3461
+ this.rafId = null;
3462
+ this.calculateProgress();
3463
+ this.updateAll();
3464
+ });
3465
+ }
3466
+ calculateProgress() {
3467
+ if (!this.container?.getBoundingClientRect) {
3468
+ this.progress = 0;
3469
+ return;
3470
+ }
3471
+ const winHeight = this.window?.innerHeight ?? FALLBACK_VIEWPORT_HEIGHT;
3472
+ const rect = this.container.getBoundingClientRect();
3473
+ const totalDist = winHeight + rect.height;
3474
+ const currentDist = winHeight - rect.top;
3475
+ this.progress = totalDist > 0 ? clamp(currentDist / totalDist, 0, 1) : 0;
3476
+ }
3477
+ updateAll() {
3478
+ for (const step of this.steps) {
3479
+ this.updateElement(step.element, step.config, this.progress);
3480
+ }
3481
+ }
3482
+ updateElement(el, cfg, globalProgress) {
3483
+ const [start, end] = cfg.range;
3484
+ if (start >= end) return;
3485
+ const style = styleOf(this.ledgers, el);
3486
+ if (!style) return;
3487
+ const localT = clamp((globalProgress - start) / (end - start), 0, 1);
3488
+ if (cfg.opacity) {
3489
+ style.set("opacity", String(lerp(cfg.opacity.from, cfg.opacity.to, localT)));
3490
+ }
3491
+ const t = buildStepTransforms(cfg, localT);
3492
+ if (t) style.set("transform", t);
3493
+ }
3494
+ /** `try`/`finally` for the reason `camera-3d.ts`'s `destroy()` documents. */
3495
+ destroy() {
3496
+ try {
3497
+ this.stop();
3498
+ } finally {
3499
+ this.ledgers.restore();
3500
+ this.steps = [];
3501
+ }
3502
+ }
3503
+ };
3504
+ function prepareScene(el, params, ctx) {
3505
+ const resolvedEnv = resolveEnv(ctx);
3506
+ const authoredProgress = params.text ? params.text("progress", "scroll") : "scroll";
3507
+ const progressMode = authoredProgress === "time" ? "time" : "scroll";
3508
+ const duration = clamp(params.num ? params.num("duration", 1e3) : 1e3, 100, 6e4);
3509
+ const htmlEl = el;
3510
+ const controller = new SceneController(
3511
+ htmlEl,
3512
+ { progress: progressMode, duration },
3513
+ resolvedEnv
3514
+ );
3515
+ const childSteps = ownedDescendants(htmlEl, "scene", "scene-step");
3516
+ for (const child of childSteps) {
3517
+ const config = parseChildStep(child);
3518
+ const [start, end] = config.range;
3519
+ if (start >= end) {
3520
+ ctx?.warn(
3521
+ `scene-step from:${start} to:${end} is an empty window \u2014 from: must be less than to:, so this step never animates`
3522
+ );
3523
+ }
3524
+ controller.addStep(child, config);
3525
+ }
3526
+ if (isReducedMotion(ctx)) {
3527
+ controller.progress = 1;
3528
+ controller.updateAll();
3529
+ return createInertInstance(() => controller.destroy());
3530
+ }
3531
+ const inst = createEffectInstance({
3532
+ continuous: progressMode === "scroll",
3533
+ activate() {
3534
+ if (progressMode === "time") {
3535
+ controller.start(() => inst.finish());
3536
+ } else {
3537
+ controller.start();
3538
+ }
3539
+ },
3540
+ cancel() {
3541
+ controller.stop();
3542
+ },
3543
+ finish() {
3544
+ controller.progress = 1;
3545
+ controller.updateAll();
3546
+ controller.stop();
3547
+ },
3548
+ destroy() {
3549
+ controller.destroy();
3550
+ }
3551
+ });
3552
+ return inst;
3553
+ }
3554
+ var SCENE_PARAMETERS = {
3555
+ progress: {
3556
+ type: "keyword",
3557
+ default: "scroll",
3558
+ keywords: ["scroll", "time"],
3559
+ cssProperty: "--kui-scene-progress"
3560
+ },
3561
+ duration: {
3562
+ type: "number",
3563
+ default: "1000",
3564
+ minimum: 100,
3565
+ maximum: 6e4,
3566
+ cssProperty: "--kui-scene-duration"
3567
+ }
3568
+ };
3569
+ var SCENE_STEP_PARAMETERS = {
3570
+ from: { type: "number", default: "0", minimum: 0, maximum: 1, cssProperty: "--kui-scene-step-from" },
3571
+ to: { type: "number", default: "1", minimum: 0, maximum: 1, cssProperty: "--kui-scene-step-to" },
3572
+ opacity: { type: "text", default: "", cssProperty: "--kui-scene-step-opacity" },
3573
+ x: { type: "text", default: "", cssProperty: "--kui-scene-step-x" },
3574
+ y: { type: "text", default: "", cssProperty: "--kui-scene-step-y" },
3575
+ scale: { type: "text", default: "", cssProperty: "--kui-scene-step-scale" }
3576
+ };
3577
+ function prepareSceneStep() {
3578
+ return createInertInstance();
3579
+ }
3580
+ var SCENE_PRIMITIVES = [
3581
+ {
3582
+ id: "scene",
3583
+ renderer: "javascript",
3584
+ channels: ["opacity", "skew"],
3585
+ parameters: SCENE_PARAMETERS,
3586
+ supportedTimelines: ["scroll", "time"],
3587
+ supportedActivations: ["load", "enter"],
3588
+ defaultActivation: "load",
3589
+ perfClass: "continuous",
3590
+ // Still `disable`: it is what stops `openGate` activating the instance, which is precisely
3591
+ // what makes the held last keyframe in `prepareScene` the only thing that runs.
3592
+ reducedMotion: "disable",
3593
+ prepare: prepareScene
3594
+ },
3595
+ {
3596
+ id: "scene-step",
3597
+ renderer: "javascript",
3598
+ // The same pair the parent declares, and for the same reason: `updateElement` writes `opacity`
3599
+ // and the `transform` *shorthand*, and per `core/types.ts`'s `CHANNEL` note anything writing
3600
+ // the shorthand is on the `skew` channel whatever the transform is for. Not `translate`/`scale`
3601
+ // — those are the independent transform properties, which this module never writes.
3602
+ channels: ["opacity", "skew"],
3603
+ parameters: SCENE_STEP_PARAMETERS,
3604
+ supportedTimelines: ["scroll", "time"],
3605
+ supportedActivations: ["load", "enter"],
3606
+ defaultActivation: "load",
3607
+ perfClass: "dom-transform",
3608
+ reducedMotion: "disable",
3609
+ prepare: prepareSceneStep
3610
+ }
3611
+ ];
3612
+ var SCENE_PRESETS = [
3613
+ { name: "scene", primitive: "scene" },
3614
+ { name: "scene-step", primitive: "scene-step" }
3615
+ ];
3616
+ function registerScenes(target) {
3617
+ return registerInto(target, SCENE_PRIMITIVES, SCENE_PRESETS, "Scenes");
3618
+ }
3619
+
3620
+ // src/advanced/camera-3d.ts
3621
+ var AUDIO_PUSH_RATIO = 0.1;
3622
+ function parseDepth(str) {
3623
+ if (!str) return 0;
3624
+ const num = parseFloat(str);
3625
+ return Number.isNaN(num) ? 0 : num;
3626
+ }
3627
+ function computeLayerTransform(depthZ, cameraZ, tiltX = 0, tiltY = 0) {
3628
+ const zOffset = depthZ + cameraZ * 0.5;
3629
+ if (tiltX || tiltY) {
3630
+ return `translate3d(0, 0, ${zOffset}px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`;
3631
+ }
3632
+ return `translate3d(0, 0, ${zOffset}px)`;
3633
+ }
3634
+ var CameraController = class {
3635
+ container;
3636
+ options;
3637
+ env;
3638
+ window;
3639
+ raf;
3640
+ caf;
3641
+ layers = [];
3642
+ cameraZ = 0;
3643
+ mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
3644
+ rafId = null;
3645
+ scrollRafId = null;
3646
+ audioRafId = null;
3647
+ /** Last value read for the selected band, 0..1. Stays 0 for a scene with no `audio:`. */
3648
+ audioLevel = 0;
3649
+ isListening = false;
3650
+ /**
3651
+ * Container and every depth layer, each ledger opened at that element's first write and shared
3652
+ * with every other controller writing to the same element — see `createAdvancedLedgers`.
3653
+ *
3654
+ * This used to say "an inner scene claims the outer scene's layers", which is backwards: an
3655
+ * inner `querySelectorAll` only ever sees its own descendants, so it is the **outer** scene that
3656
+ * over-reaches into the inner one. `prepareCameraScene` now scopes the scan with
3657
+ * {@link ownedDescendants}, so an outer camera stops at a nested `camera-scene` and the two no
3658
+ * longer write one layer's `transform` on alternate frames.
3659
+ *
3660
+ * Sharing still matters after that fix, and not only for the controllers built directly by the
3661
+ * suites: a `camera-layer` that is *also* a `scene-step` has two owners by design, because they
3662
+ * are different kinds of host. The ledger is what keeps that case honest.
3663
+ */
3664
+ ledgers;
3665
+ constructor(container, options = {}, env = {}) {
3666
+ this.container = container;
3667
+ this.options = options;
3668
+ this.env = env;
3669
+ const resolved = resolveEnv(null, env);
3670
+ this.window = resolved.window;
3671
+ this.raf = resolved.raf;
3672
+ this.caf = resolved.caf;
3673
+ this.ledgers = createAdvancedLedgers(container);
3674
+ this.onScroll = this.onScroll.bind(this);
3675
+ this.onMouseMove = this.onMouseMove.bind(this);
3676
+ }
3677
+ addLayer(element, depthZ) {
3678
+ this.layers.push({ element, depthZ });
3679
+ }
3680
+ applyLayer3D() {
3681
+ for (const layer of this.layers) {
3682
+ styleOf(this.ledgers, layer.element)?.set("transform-style", "preserve-3d");
3683
+ }
3684
+ }
3685
+ bindEvents() {
3686
+ if (this.window?.addEventListener) {
3687
+ this.window.addEventListener("scroll", this.onScroll, { passive: true });
3688
+ if (this.options.mouseTilt) {
3689
+ this.window.addEventListener("pointermove", this.onMouseMove, { passive: true });
3690
+ }
3691
+ }
3692
+ }
3693
+ /**
3694
+ * The 3D space itself: the container's perspective and every layer's `preserve-3d`.
3695
+ *
3696
+ * Separated from {@link start} because the reduced-motion path needs exactly this and none of
3697
+ * the listeners or loops below it — see {@link renderStatic}.
3698
+ */
3699
+ applyStage() {
3700
+ const stage = styleOf(this.ledgers, this.container);
3701
+ stage?.set("perspective", `${this.options.depth || 1e3}px`);
3702
+ stage?.set("transform-style", "preserve-3d");
3703
+ this.applyLayer3D();
3704
+ }
3705
+ /**
3706
+ * The scene composed once, with the camera parked where its travel begins.
3707
+ *
3708
+ * What a visitor who asked for reduced motion gets instead of nothing, following the decision
3709
+ * `shaders.ts` records: a static frame where one genuinely exists. Here it does, and it is most
3710
+ * of the effect — `depth` and each layer's `z:` *are* the composition, and the scroll only moves
3711
+ * a camera through an arrangement the author already described. One `render()` at `cameraZ = 0`
3712
+ * puts every layer at its authored depth through the container's perspective, so the scene keeps
3713
+ * its real scale hierarchy and simply does not travel. Without it the layers are written nothing
3714
+ * at all and the whole scene collapses flat — the same bug `start()`'s own `render()` call fixed
3715
+ * for the live path.
3716
+ *
3717
+ * Deliberately *not* the scene's end state, which is where the animator leaves a CSS effect under
3718
+ * this policy (`openGate`, `core/animator.ts`). "The end" here is `cameraZ = depth`, i.e. the
3719
+ * camera pushed all the way through: a layer then sits at `z = depthZ + depth / 2`, which against
3720
+ * a `perspective` of `depth` is a 2x blow-up at `z:0` and 10x at `z:400px` of a 1000px scene, with
3721
+ * nearer layers passing the viewer entirely. An end state nobody can see is not a still frame.
3722
+ *
3723
+ * Runs during `prepare`, because it has to — under reduced motion `openGate` marks the element
3724
+ * finished and never activates any instance. See `prepareReducedMotion` in `shaders.ts`.
3725
+ */
3726
+ renderStatic() {
3727
+ if (!this.container) return;
3728
+ this.applyStage();
3729
+ this.render();
3730
+ }
3731
+ start() {
3732
+ if (this.isListening || !this.container) return;
3733
+ this.applyStage();
3734
+ this.bindEvents();
3735
+ this.isListening = true;
3736
+ this.updateScroll();
3737
+ this.readAudio();
3738
+ this.render();
3739
+ this.startAudioLoop();
3740
+ this.startLoop();
3741
+ }
3742
+ stop() {
3743
+ if (!this.isListening) return;
3744
+ if (this.window?.removeEventListener) {
3745
+ this.window.removeEventListener("scroll", this.onScroll);
3746
+ this.window.removeEventListener("pointermove", this.onMouseMove);
3747
+ }
3748
+ if (this.rafId) this.caf(this.rafId);
3749
+ this.rafId = null;
3750
+ if (this.scrollRafId) this.caf(this.scrollRafId);
3751
+ this.scrollRafId = null;
3752
+ if (this.audioRafId) this.caf(this.audioRafId);
3753
+ this.audioRafId = null;
3754
+ this.isListening = false;
3755
+ }
3756
+ updateScroll() {
3757
+ if (!this.container?.getBoundingClientRect) return;
3758
+ const winHeight = this.window?.innerHeight ?? 800;
3759
+ const rect = this.container.getBoundingClientRect();
3760
+ const travel = winHeight + rect.height;
3761
+ const dist = winHeight - rect.top;
3762
+ const progress = travel > 0 ? clamp(dist / travel, 0, 1) : 0;
3763
+ this.cameraZ = progress * (this.options.depth || 1e3);
3764
+ }
3765
+ /** The selected band's current value, or 0 for a scene that asked for none. */
3766
+ readAudio() {
3767
+ this.audioLevel = this.options.audio ? readAudioBand(this.container, this.options.audio) : 0;
3768
+ }
3769
+ /**
3770
+ * The one frame loop an audio-driven scene runs, doing every read before every write.
3771
+ *
3772
+ * An audio band changes on its own, without a scroll or a pointer move to hang a frame on, so
3773
+ * this scene needs a loop of its own — and then it needs to be the *only* loop, which is why
3774
+ * `onScroll` and `startLoop` both stand down while it is running. Three callbacks each reading
3775
+ * geometry (`getBoundingClientRect`) or style (`readAudioBand`'s computed fallback) and then
3776
+ * writing transforms would force a layout or style recalculation per callback per frame; one
3777
+ * pass that reads scroll position, reads the band, steps the pointer spring and only then
3778
+ * renders forces at most one.
3779
+ */
3780
+ startAudioLoop() {
3781
+ if (this.audioRafId || !this.options.audio) return;
3782
+ const tick = () => {
3783
+ this.updateScroll();
3784
+ this.readAudio();
3785
+ if (this.options.mouseTilt) this.stepMouse();
3786
+ this.render();
3787
+ this.audioRafId = this.isListening ? this.raf(tick) : null;
3788
+ };
3789
+ this.audioRafId = this.raf(tick);
3790
+ }
3791
+ onScroll() {
3792
+ if (this.scrollRafId || this.audioRafId) return;
3793
+ const id = this.raf(() => {
3794
+ this.scrollRafId = null;
3795
+ this.updateScroll();
3796
+ this.render();
3797
+ });
3798
+ if (id == null) {
3799
+ this.updateScroll();
3800
+ this.render();
3801
+ } else {
3802
+ this.scrollRafId = id;
3803
+ }
3804
+ }
3805
+ onMouseMove(e) {
3806
+ if (!this.options.mouseTilt) return;
3807
+ const winWidth = this.window?.innerWidth ?? 1e3;
3808
+ const winHeight = this.window?.innerHeight ?? 800;
3809
+ const cx = winWidth * 0.5;
3810
+ const cy = winHeight * 0.5;
3811
+ this.mouse.targetX = (e.clientX - cx) / cx;
3812
+ this.mouse.targetY = (e.clientY - cy) / cy;
3813
+ this.startLoop();
3814
+ }
3815
+ stepMouse() {
3816
+ const dx = this.mouse.targetX - this.mouse.x;
3817
+ const dy = this.mouse.targetY - this.mouse.y;
3818
+ this.mouse.x += dx * 0.08;
3819
+ this.mouse.y += dy * 0.08;
3820
+ if (Math.abs(dx) < 1e-3 && Math.abs(dy) < 1e-3) {
3821
+ this.mouse.x = this.mouse.targetX;
3822
+ this.mouse.y = this.mouse.targetY;
3823
+ return false;
3824
+ }
3825
+ return true;
3826
+ }
3827
+ startLoop() {
3828
+ if (this.rafId || this.audioRafId || !this.options.mouseTilt) return;
3829
+ const tick = () => {
3830
+ const moving = this.stepMouse();
3831
+ this.render();
3832
+ if (this.isListening && moving) {
3833
+ this.rafId = this.raf(tick);
3834
+ } else {
3835
+ this.rafId = null;
3836
+ }
3837
+ };
3838
+ this.rafId = this.raf(tick);
3839
+ }
3840
+ /**
3841
+ * Tilt the whole stage toward the pointer.
3842
+ *
3843
+ * The author's own transform is read here, at the first frame that overwrites it, rather than
3844
+ * when the effect activated. Between those two moments sits at least one frame in which the
3845
+ * page's own code can write a transform, and a capture taken at activation would both overwrite
3846
+ * that write and hand the older value back on destroy as if it had never happened.
3847
+ *
3848
+ * `claim()` is what makes that true without a private snapshot field: it remembers `transform`'s
3849
+ * current value the first time this runs and is a no-op on every call after, so `peek()` always
3850
+ * answers with whatever was there just before this method's first write — the ledger already
3851
+ * tracks that for `restore()`; `peek()` is just asking it without writing.
3852
+ */
3853
+ renderStageTilt(tiltX, tiltY) {
3854
+ const stage = styleOf(this.ledgers, this.container);
3855
+ if (!stage) return;
3856
+ stage.claim("transform");
3857
+ if (tiltX !== 0 || tiltY !== 0) {
3858
+ stage.set("transform", `rotateX(${tiltX.toFixed(2)}deg) rotateY(${tiltY.toFixed(2)}deg)`);
3859
+ return;
3860
+ }
3861
+ this.restStageTransform(stage);
3862
+ }
3863
+ /**
3864
+ * Hand `transform` back when the tilt settles at exactly zero.
3865
+ *
3866
+ * `peek()` reports only what `restore()` would put back, which is the **inline** capture and
3867
+ * nothing else — by design (`owned-styles.ts`, and `test/owned-styles.test.ts` asserts it). So
3868
+ * `peek() || 'rotateX(0deg) rotateY(0deg)'` was right only for an author who had written the
3869
+ * transform inline. For the ordinary case — `.hero { transform: translateY(-20px) }` in a
3870
+ * stylesheet — `peek()` answers `''` and the fallback wrote a *fabricated* identity inline,
3871
+ * which outranks the class and switches the author's own transform off the moment the effect
3872
+ * activates. `start()` renders immediately with the pointer at the synthetic centre, so that is
3873
+ * activation, not some edge case.
3874
+ *
3875
+ * Removing the declaration instead is what actually lands on "the author's own transform": their
3876
+ * inline value if they had one, their stylesheet's if they did not, and nothing at all if
3877
+ * neither. The `removeProperty` is not a ledger bypass — `claim()` above has already recorded
3878
+ * the property, `peek()` answering `''` *is* the ledger saying it found no inline declaration
3879
+ * here, and removal is precisely what its own `restore()` does for that capture.
3880
+ *
3881
+ * Not fixed here: an inline `transform: scale(1.2) !important` comes back through `peek()`
3882
+ * without its priority, so while the effect is live at rest a competing `!important` rule the
3883
+ * author had deliberately beaten wins again. `restore()` puts the priority back, so it ends at
3884
+ * the right value. Closing that needs a per-property give-back on `StyleLedger` (core).
3885
+ *
3886
+ * @complexity O(1).
3887
+ */
3888
+ restStageTransform(stage) {
3889
+ const authored = stage.peek("transform");
3890
+ if (authored) {
3891
+ stage.set("transform", authored);
3892
+ return;
3893
+ }
3894
+ this.container.style.removeProperty("transform");
3895
+ }
3896
+ render() {
3897
+ if (this.options.mouseTilt) {
3898
+ this.renderStageTilt(this.mouse.y * -8, this.mouse.x * 8);
3899
+ }
3900
+ const cameraZ = this.cameraZ + this.audioLevel * (this.options.depth || 1e3) * AUDIO_PUSH_RATIO;
3901
+ for (const layer of this.layers) {
3902
+ styleOf(this.ledgers, layer.element)?.set(
3903
+ "transform",
3904
+ computeLayerTransform(layer.depthZ, cameraZ)
3905
+ );
3906
+ }
3907
+ }
3908
+ /**
3909
+ * `try`/`finally` because the restore must not be hostage to the listener teardown.
3910
+ *
3911
+ * The animator swallows a throwing instance teardown (`runQuietly(() => instance.destroy())`),
3912
+ * and every element this controller reached on its own — the layers — is in no ledger core will
3913
+ * ever restore. A `removeEventListener` the page has replaced, or a window torn down under a
3914
+ * detached document, would otherwise leave `transform` on each layer permanently.
3915
+ */
3916
+ destroy() {
3917
+ try {
3918
+ this.stop();
3919
+ } finally {
3920
+ this.ledgers.restore();
3921
+ this.layers = [];
3922
+ }
3923
+ }
3924
+ };
3925
+ function parseMouseTilt(params) {
3926
+ if (params.is) return params.is("mouse-tilt", "on");
3927
+ if (params.text) return params.text("mouse-tilt", "on") === "on";
3928
+ return true;
3929
+ }
3930
+ function prepareCameraScene(el, params, ctx) {
3931
+ const resolvedEnv = resolveEnv(ctx);
3932
+ const depth = clamp(params.num ? params.num("depth", 1e3) : 1e3, 100, 5e3);
3933
+ const mouseTilt = parseMouseTilt(params);
3934
+ const audio = parseAudioBand(params.text ? params.text("audio", "off") : "off");
3935
+ const reduced = isReducedMotion(ctx);
3936
+ const options = reduced ? { depth, mouseTilt: false, audio: null } : { depth, mouseTilt, audio };
3937
+ const htmlEl = el;
3938
+ const controller = new CameraController(htmlEl, options, resolvedEnv);
3939
+ const layerEls = ownedDescendants(htmlEl, "camera-scene", "camera-layer");
3940
+ for (const layer of layerEls) {
3941
+ const kui = layer.getAttribute("data-kui") || "";
3942
+ const zMatch = /z:([+-]?[0-9.]+)(?:px)?/.exec(kui);
3943
+ const depthZ = zMatch ? parseDepth(zMatch[1]) : 0;
3944
+ controller.addLayer(layer, depthZ);
3945
+ }
3946
+ if (reduced) {
3947
+ controller.renderStatic();
3948
+ return createInertInstance(() => controller.destroy());
3949
+ }
3950
+ return createEffectInstance({
3951
+ continuous: true,
3952
+ activate() {
3953
+ controller.start();
3954
+ },
3955
+ cancel() {
3956
+ controller.stop();
3957
+ },
3958
+ finish() {
3959
+ controller.stop();
3960
+ },
3961
+ destroy() {
3962
+ controller.destroy();
3963
+ }
3964
+ });
3965
+ }
3966
+ var CAMERA_PARAMETERS = {
3967
+ depth: { type: "number", default: "1000", minimum: 100, maximum: 5e3, cssProperty: "--kui-camera-depth" },
3968
+ "mouse-tilt": {
3969
+ type: "keyword",
3970
+ default: "on",
3971
+ keywords: ["on", "off"],
3972
+ cssProperty: "--kui-camera-tilt"
3973
+ },
3974
+ /**
3975
+ * Which `audio-source` band pushes the camera forward, if any.
3976
+ *
3977
+ * Same name, same words and the same `off` default as the `shaders` primitive's: one spelling
3978
+ * for "follow this band" across this directory rather than a second word per consumer. Opt-in
3979
+ * for the reason that one is — the value is written by another primitive, and a scene that read
3980
+ * it unasked would start pulsing the moment an unrelated `audio-source` appeared above it.
3981
+ *
3982
+ * Turning it on also changes *how* the scene renders: see `startAudioLoop`, which becomes the
3983
+ * scene's only frame loop.
3984
+ */
3985
+ audio: {
3986
+ type: "keyword",
3987
+ default: "off",
3988
+ keywords: ["off", ...AUDIO_BANDS],
3989
+ cssProperty: "--kui-camera-audio"
3990
+ }
3991
+ };
3992
+ var CAMERA_LAYER_PARAMETERS = {
3993
+ z: { type: "number", default: "0", minimum: -5e3, maximum: 5e3, cssProperty: "--kui-camera-layer-z" }
3994
+ };
3995
+ function prepareCameraLayer() {
3996
+ return createInertInstance();
3997
+ }
3998
+ var CAMERA_PRIMITIVES = [
3999
+ {
4000
+ id: "camera-scene",
4001
+ renderer: "javascript",
4002
+ channels: ["skew", "perspective", "transform-style"],
4003
+ parameters: CAMERA_PARAMETERS,
4004
+ supportedTimelines: ["scroll", "time"],
4005
+ supportedActivations: ["load", "enter"],
4006
+ defaultActivation: "load",
4007
+ perfClass: "continuous",
4008
+ // Still `disable`, and it has to be: it is what makes `openGate` refuse to activate this
4009
+ // instance, and the still frame is baked in `prepare` precisely because nothing else runs.
4010
+ // `shaders.ts` keeps its own for the same reason.
4011
+ reducedMotion: "disable",
4012
+ prepare: prepareCameraScene
4013
+ },
4014
+ {
4015
+ id: "camera-layer",
4016
+ renderer: "javascript",
4017
+ channels: ["translate", "rotate", "transform-style"],
4018
+ parameters: CAMERA_LAYER_PARAMETERS,
4019
+ supportedTimelines: ["scroll", "time"],
4020
+ supportedActivations: ["load", "enter"],
4021
+ defaultActivation: "load",
4022
+ perfClass: "dom-transform",
4023
+ // Nothing to reconsider here when `camera-scene` gained a still frame: this primitive animates
4024
+ // nothing in either mode. The scene above writes every layer's transform, including the static
4025
+ // one, so a layer is already covered by whatever its scene decided.
4026
+ reducedMotion: "disable",
4027
+ prepare: prepareCameraLayer
4028
+ }
4029
+ ];
4030
+ var CAMERA_PRESETS = [
4031
+ { name: "camera-scene", primitive: "camera-scene" },
4032
+ { name: "camera-layer", primitive: "camera-layer" }
4033
+ ];
4034
+ function registerCamera(target) {
4035
+ return registerInto(target, CAMERA_PRIMITIVES, CAMERA_PRESETS, "Camera");
4036
+ }
4037
+
4038
+ // src/advanced/particles.ts
4039
+ function stepParticle(p, mouseX, mouseY, repelRadius) {
4040
+ const dx = mouseX - p.x;
4041
+ const dy = mouseY - p.y;
4042
+ const dist = Math.hypot(dx, dy);
4043
+ if (dist < repelRadius && dist > 0) {
4044
+ const force = (repelRadius - dist) / repelRadius;
4045
+ p.vx -= dx / dist * force * 5;
4046
+ p.vy -= dy / dist * force * 5;
4047
+ }
4048
+ const ox = p.originX - p.x;
4049
+ const oy = p.originY - p.y;
4050
+ p.vx += ox * 0.05;
4051
+ p.vy += oy * 0.05;
4052
+ p.vx *= 0.88;
4053
+ p.vy *= 0.88;
4054
+ p.x += p.vx;
4055
+ p.y += p.vy;
4056
+ }
4057
+ function spawnParticlesGrid(count, color, width, height) {
4058
+ const cols = Math.max(1, Math.round(Math.sqrt(count * (width / Math.max(height, 1)))));
4059
+ const rows = Math.max(1, Math.ceil(count / cols));
4060
+ const particles = [];
4061
+ for (let i = 0; i < count; i++) {
4062
+ const originX = i % cols * (width / cols) + width / (cols * 2);
4063
+ const originY = Math.floor(i / cols) * (height / rows) + height / (rows * 2);
4064
+ particles.push({
4065
+ x: originX,
4066
+ y: originY,
4067
+ originX,
4068
+ originY,
4069
+ vx: 0,
4070
+ vy: 0,
4071
+ radius: i % 3 + 1.5,
4072
+ color
4073
+ });
4074
+ }
4075
+ return particles;
4076
+ }
4077
+ function isParticleMoving(p) {
4078
+ if (Math.abs(p.vx) > 0.02 || Math.abs(p.vy) > 0.02) return true;
4079
+ return Math.abs(p.x - p.originX) > 0.5 || Math.abs(p.y - p.originY) > 0.5;
4080
+ }
4081
+ var ParticleEmitter = class {
4082
+ element;
4083
+ options;
4084
+ env;
4085
+ window;
4086
+ document;
4087
+ createCanvas;
4088
+ raf;
4089
+ caf;
4090
+ canvas = null;
4091
+ ctx = null;
4092
+ particles = [];
4093
+ mouse = { x: -999, y: -999 };
4094
+ rafId = null;
4095
+ isListening = false;
4096
+ dpr = 1;
4097
+ /**
4098
+ * The host's `position`, and what it was before this module needed a positioning context.
4099
+ *
4100
+ * The hand-rolled pair this replaced read back only the value, never the priority, so an
4101
+ * author's `position: relative !important` came back as a plain declaration on teardown. The
4102
+ * canvas's own `cssText` needs no ledger — that element is created and removed here.
4103
+ *
4104
+ * Shared with every other controller writing to this element — see `createAdvancedLedgers`.
4105
+ */
4106
+ ledgers;
4107
+ constructor(element, options = {}, env = {}) {
4108
+ this.element = element;
4109
+ this.options = options;
4110
+ this.env = env;
4111
+ this.ledgers = createAdvancedLedgers(element);
4112
+ const resolved = resolveEnv(null, env);
4113
+ this.window = resolved.window;
4114
+ this.document = resolved.document;
4115
+ this.createCanvas = resolved.createCanvas;
4116
+ this.raf = resolved.raf;
4117
+ this.caf = resolved.caf;
4118
+ this.onMouseMove = this.onMouseMove.bind(this);
4119
+ this.onMouseLeave = this.onMouseLeave.bind(this);
4120
+ this.onResize = this.onResize.bind(this);
4121
+ }
4122
+ syncDimensions() {
4123
+ if (!this.canvas || !this.element?.getBoundingClientRect) return 1;
4124
+ const rect = this.element.getBoundingClientRect();
4125
+ this.dpr = Math.min(this.window?.devicePixelRatio ?? 1, 2);
4126
+ const cw = Math.round(rect.width * this.dpr);
4127
+ const ch = Math.round(rect.height * this.dpr);
4128
+ if (this.canvas.width !== cw || this.canvas.height !== ch) {
4129
+ this.canvas.width = cw;
4130
+ this.canvas.height = ch;
4131
+ this.canvas.style.width = `${rect.width}px`;
4132
+ this.canvas.style.height = `${rect.height}px`;
4133
+ }
4134
+ return this.dpr;
4135
+ }
4136
+ setupPosition() {
4137
+ if (!this.window?.getComputedStyle) return;
4138
+ if (this.window.getComputedStyle(this.element).position !== "static") return;
4139
+ styleOf(this.ledgers, this.element)?.set("position", "relative");
4140
+ }
4141
+ init() {
4142
+ if (!this.document || !this.element?.getBoundingClientRect) return false;
4143
+ this.canvas = this.createCanvas();
4144
+ if (!this.canvas) return false;
4145
+ this.canvas.className = "kui-particle-canvas";
4146
+ this.canvas.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;z-index:10;";
4147
+ this.ctx = this.canvas.getContext ? this.canvas.getContext("2d") : null;
4148
+ if (!this.ctx) return false;
4149
+ this.setupPosition();
4150
+ this.element.appendChild(this.canvas);
4151
+ this.syncDimensions();
4152
+ const rect = this.element.getBoundingClientRect();
4153
+ this.spawnParticles(rect.width, rect.height);
4154
+ this.bindEvents();
4155
+ return true;
4156
+ }
4157
+ spawnParticles(width, height) {
4158
+ const count = this.options.count || 60;
4159
+ const color = this.options.color || "#e4f222";
4160
+ this.particles = spawnParticlesGrid(count, color, width, height);
4161
+ }
4162
+ resizeObserver = null;
4163
+ bindEvents() {
4164
+ if (this.isListening) return;
4165
+ this.element.addEventListener("pointermove", this.onMouseMove, { passive: true });
4166
+ this.element.addEventListener("pointerleave", this.onMouseLeave, { passive: true });
4167
+ if (typeof ResizeObserver !== "undefined") {
4168
+ this.resizeObserver = new ResizeObserver(() => this.onResize());
4169
+ this.resizeObserver.observe(this.element);
4170
+ } else if (this.window?.addEventListener) {
4171
+ this.window.addEventListener("resize", this.onResize, { passive: true });
4172
+ }
4173
+ this.isListening = true;
4174
+ }
4175
+ unbindEvents() {
4176
+ if (!this.isListening) return;
4177
+ if (this.element?.removeEventListener) {
4178
+ this.element.removeEventListener("pointermove", this.onMouseMove);
4179
+ this.element.removeEventListener("pointerleave", this.onMouseLeave);
4180
+ }
4181
+ if (this.resizeObserver) {
4182
+ this.resizeObserver.disconnect();
4183
+ this.resizeObserver = null;
4184
+ }
4185
+ if (this.window?.removeEventListener) {
4186
+ this.window.removeEventListener("resize", this.onResize);
4187
+ }
4188
+ this.isListening = false;
4189
+ }
4190
+ onResize() {
4191
+ this.syncDimensions();
4192
+ if (this.element?.getBoundingClientRect) {
4193
+ const rect = this.element.getBoundingClientRect();
4194
+ if (rect.width > 0 && rect.height > 0) {
4195
+ this.spawnParticles(rect.width, rect.height);
4196
+ }
4197
+ }
4198
+ this.draw();
4199
+ }
4200
+ onMouseMove(e) {
4201
+ if (!this.canvas?.getBoundingClientRect) return;
4202
+ const rect = this.canvas.getBoundingClientRect();
4203
+ this.mouse.x = e.clientX - rect.left;
4204
+ this.mouse.y = e.clientY - rect.top;
4205
+ this.startLoop();
4206
+ }
4207
+ onMouseLeave() {
4208
+ this.mouse.x = -999;
4209
+ this.mouse.y = -999;
4210
+ }
4211
+ startLoop() {
4212
+ if (this.rafId) return;
4213
+ const tick = () => {
4214
+ const moving = this.update();
4215
+ this.draw();
4216
+ if (this.isListening && (moving || this.mouse.x !== -999)) {
4217
+ this.rafId = this.raf(tick);
4218
+ } else {
4219
+ this.rafId = null;
4220
+ }
4221
+ };
4222
+ this.rafId = this.raf(tick);
4223
+ }
4224
+ stopLoop() {
4225
+ if (this.rafId) {
4226
+ this.caf(this.rafId);
4227
+ this.rafId = null;
4228
+ }
4229
+ }
4230
+ update() {
4231
+ const repelRadius = this.options.radius || 80;
4232
+ let anyMoving = false;
4233
+ for (const p of this.particles) {
4234
+ stepParticle(p, this.mouse.x, this.mouse.y, repelRadius);
4235
+ if (isParticleMoving(p)) {
4236
+ anyMoving = true;
4237
+ }
4238
+ }
4239
+ return anyMoving;
4240
+ }
4241
+ draw() {
4242
+ if (!this.ctx || !this.canvas) return;
4243
+ const dpr = this.dpr || 1;
4244
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
4245
+ for (const p of this.particles) {
4246
+ this.ctx.beginPath();
4247
+ this.ctx.arc(p.x * dpr, p.y * dpr, p.radius * dpr, 0, Math.PI * 2);
4248
+ this.ctx.fillStyle = p.color;
4249
+ this.ctx.fill();
4250
+ }
4251
+ }
4252
+ /** `try`/`finally` for the reason `camera-3d.ts`'s `destroy()` documents. */
4253
+ destroy() {
4254
+ try {
4255
+ this.stopLoop();
4256
+ this.unbindEvents();
4257
+ } finally {
4258
+ this.ledgers.restore();
4259
+ this.removeCanvas();
4260
+ }
4261
+ }
4262
+ removeCanvas() {
4263
+ this.canvas?.remove();
4264
+ this.canvas = null;
4265
+ this.ctx = null;
4266
+ this.particles = [];
4267
+ }
4268
+ };
4269
+ function prepareParticles(el, params, ctx) {
4270
+ if (isReducedMotion(ctx)) return createInertInstance();
4271
+ const resolvedEnv = resolveEnv(ctx);
4272
+ const count = clamp(params.num ? params.num("count", 60) : 60, 10, 300);
4273
+ const radius = clamp(params.num ? params.num("radius", 80) : 80, 20, 300);
4274
+ const color = params.text ? params.text("color", "#e4f222") : "#e4f222";
4275
+ const htmlEl = el;
4276
+ const emitter = new ParticleEmitter(htmlEl, { count, radius, color }, resolvedEnv);
4277
+ let isMounted = false;
4278
+ return createEffectInstance({
4279
+ continuous: true,
4280
+ activate() {
4281
+ if (!isMounted) {
4282
+ isMounted = emitter.init();
4283
+ } else if (!emitter.isListening) {
4284
+ emitter.bindEvents();
4285
+ }
4286
+ if (isMounted) {
4287
+ emitter.startLoop();
4288
+ }
4289
+ },
4290
+ cancel() {
4291
+ emitter.stopLoop();
4292
+ emitter.unbindEvents();
4293
+ },
4294
+ finish() {
4295
+ emitter.stopLoop();
4296
+ emitter.unbindEvents();
4297
+ },
4298
+ destroy() {
4299
+ if (isMounted) {
4300
+ emitter.destroy();
4301
+ isMounted = false;
4302
+ }
4303
+ }
4304
+ });
4305
+ }
4306
+ var PARTICLE_PARAMETERS = {
4307
+ count: { type: "number", default: "60", minimum: 10, maximum: 300, cssProperty: "--kui-particle-count" },
4308
+ radius: { type: "number", default: "80", minimum: 20, maximum: 300, cssProperty: "--kui-particle-radius" },
4309
+ color: { type: "color", default: "#e4f222", cssProperty: "--kui-particle-color" }
4310
+ };
4311
+ var PARTICLE_PRIMITIVES = [
4312
+ {
4313
+ id: "particle-dissolve",
4314
+ renderer: "javascript",
4315
+ channels: ["position"],
4316
+ parameters: PARTICLE_PARAMETERS,
4317
+ supportedTimelines: ["time"],
4318
+ supportedActivations: ["hover", "load"],
4319
+ defaultActivation: "hover",
4320
+ perfClass: "continuous",
4321
+ reducedMotion: "disable",
4322
+ prepare: prepareParticles
4323
+ }
4324
+ ];
4325
+ var PARTICLE_PRESETS = [
4326
+ { name: "particle-dissolve", primitive: "particle-dissolve" }
4327
+ ];
4328
+ function registerParticles(target) {
4329
+ return registerInto(target, PARTICLE_PRIMITIVES, PARTICLE_PRESETS, "Particles");
4330
+ }
4331
+
4332
+ // src/advanced/fluid-cursor.ts
4333
+ function stepFluidDrop(drop, friction) {
4334
+ drop.x += drop.vx;
4335
+ drop.y += drop.vy;
4336
+ drop.vx *= friction;
4337
+ drop.vy *= friction;
4338
+ drop.alpha *= 0.94;
4339
+ return drop.alpha < 0.02;
4340
+ }
4341
+ function syncCanvasStyleBox(canvas, vw, vh) {
4342
+ const style = canvas.style;
4343
+ if (!style) return;
4344
+ const cssW = `${vw}px`;
4345
+ const cssH = `${vh}px`;
4346
+ if (style.width !== cssW) style.width = cssW;
4347
+ if (style.height !== cssH) style.height = cssH;
4348
+ }
4349
+ function syncCanvasSize(canvas, win) {
4350
+ if (!canvas) return 1;
4351
+ const dpr = Math.min(win?.devicePixelRatio ?? 1, 2);
4352
+ const vw = win?.innerWidth ?? 1e3;
4353
+ const vh = win?.innerHeight ?? 800;
4354
+ const w = Math.round(vw * dpr);
4355
+ const h = Math.round(vh * dpr);
4356
+ if (canvas.width !== w || canvas.height !== h) {
4357
+ canvas.width = w;
4358
+ canvas.height = h;
4359
+ }
4360
+ syncCanvasStyleBox(canvas, vw, vh);
4361
+ return dpr;
4362
+ }
4363
+ var FluidTrail = class {
4364
+ options;
4365
+ env;
4366
+ window;
4367
+ document;
4368
+ createCanvas;
4369
+ raf;
4370
+ caf;
4371
+ drops = [];
4372
+ canvas = null;
4373
+ ctx = null;
4374
+ rafId = null;
4375
+ isListening = false;
4376
+ lastMouse = { x: 0, y: 0 };
4377
+ hasLastMouse = false;
4378
+ wasOutside = false;
4379
+ refCount = 0;
4380
+ clients = /* @__PURE__ */ new Map();
4381
+ activeClients = /* @__PURE__ */ new Map();
4382
+ nextClientId = 1;
4383
+ onResize;
4384
+ onPointerMove;
4385
+ mapKey;
4386
+ constructor(options = {}, env = {}) {
4387
+ this.options = options;
4388
+ this.env = env;
4389
+ const resolved = resolveEnv(null, env);
4390
+ this.window = resolved.window;
4391
+ this.document = resolved.document;
4392
+ this.createCanvas = resolved.createCanvas;
4393
+ this.raf = resolved.raf;
4394
+ this.caf = resolved.caf;
4395
+ this.mapKey = resolved.document ?? resolved.window ?? fallbackFluidKey;
4396
+ }
4397
+ acquire() {
4398
+ if (this.ctx) {
4399
+ this.refCount++;
4400
+ return true;
4401
+ }
4402
+ const ok = this.init();
4403
+ if (ok) this.refCount++;
4404
+ return ok;
4405
+ }
4406
+ release() {
4407
+ if (this.refCount > 0) this.refCount--;
4408
+ if (this.refCount === 0 && this.clients.size === 0) this.destroy();
4409
+ }
4410
+ registerClient(options, el) {
4411
+ const id = this.nextClientId++;
4412
+ this.clients.set(id, { options, el });
4413
+ return id;
4414
+ }
4415
+ activateClient(id) {
4416
+ const client = this.clients.get(id);
4417
+ if (!client) return false;
4418
+ this.activeClients.set(id, client);
4419
+ if (!this.canvas && !this.setupCanvas()) return false;
4420
+ this.attachListeners();
4421
+ this.startLoop();
4422
+ return true;
4423
+ }
4424
+ deactivateClient(id) {
4425
+ this.activeClients.delete(id);
4426
+ if (this.activeClients.size > 0 || this.refCount > 0) return;
4427
+ this.detachListeners();
4428
+ this.stopLoop();
4429
+ this.clearOverlay();
4430
+ }
4431
+ /**
4432
+ * Wipe the overlay when the last client stands down.
4433
+ *
4434
+ * `draw()` clears the canvas at the *start* of a frame, so stopping the loop leaves whatever was
4435
+ * in flight painted. The overlay is `position: fixed` at `z-index: 9998` across the whole
4436
+ * viewport, so a `fluid-trail` on `on:pointerenter`/`pointerleave` — or an author's
4437
+ * `control.cancel()` mid-move — left a frozen blob of drops over the entire page until something
4438
+ * reactivated it or the element was destroyed.
4439
+ *
4440
+ * The canvas itself stays mounted on purpose: registration happens at prepare, so the last
4441
+ * *unregister* owns the teardown and no client can have the canvas pulled from under it.
4442
+ *
4443
+ * @complexity O(1).
4444
+ */
4445
+ clearOverlay() {
4446
+ this.drops = [];
4447
+ const canvas = this.canvas;
4448
+ if (!canvas || typeof this.ctx?.clearRect !== "function") return;
4449
+ this.ctx.clearRect(0, 0, canvas.width, canvas.height);
4450
+ }
4451
+ unregisterClient(id) {
4452
+ this.deactivateClient(id);
4453
+ this.clients.delete(id);
4454
+ if (this.clients.size === 0 && this.refCount === 0) {
4455
+ this.destroy();
4456
+ }
4457
+ }
4458
+ setupCanvas() {
4459
+ if (this.canvas && this.ctx) return true;
4460
+ if (!this.document?.body?.appendChild) return false;
4461
+ this.canvas = this.createCanvas();
4462
+ if (!this.canvas) return false;
4463
+ this.canvas.className = "kui-fluid-canvas";
4464
+ this.canvas.style.cssText = "position:fixed;top:0;left:0;pointer-events:none;z-index:9998;";
4465
+ this.ctx = this.canvas.getContext ? this.canvas.getContext("2d") : null;
4466
+ if (!this.ctx) {
4467
+ this.canvas = null;
4468
+ return false;
4469
+ }
4470
+ this.document.body.appendChild(this.canvas);
4471
+ syncCanvasSize(this.canvas, this.window);
4472
+ return true;
4473
+ }
4474
+ attachListeners() {
4475
+ if (this.isListening) return;
4476
+ this.onResize = () => syncCanvasSize(this.canvas, this.window);
4477
+ this.onPointerMove = (e) => {
4478
+ const client = this.resolveClientOwner(e);
4479
+ if (!client) {
4480
+ this.lastMouse.x = e.clientX;
4481
+ this.lastMouse.y = e.clientY;
4482
+ this.wasOutside = true;
4483
+ return;
4484
+ }
4485
+ const isEntry = this.wasOutside;
4486
+ const dx = isEntry ? 0 : e.clientX - this.lastMouse.x;
4487
+ const dy = isEntry ? 0 : e.clientY - this.lastMouse.y;
4488
+ this.wasOutside = false;
4489
+ if (Math.hypot(dx, dy) > 2 || isEntry || !this.hasLastMouse) {
4490
+ this.hasLastMouse = true;
4491
+ const vx = Math.max(-20, Math.min(20, dx * 0.2));
4492
+ const vy = Math.max(-20, Math.min(20, dy * 0.2));
4493
+ this.spawnDrop(e.clientX, e.clientY, vx, vy, client.options);
4494
+ this.startLoop();
4495
+ }
4496
+ this.lastMouse.x = e.clientX;
4497
+ this.lastMouse.y = e.clientY;
4498
+ };
4499
+ if (this.window?.addEventListener) {
4500
+ this.window.addEventListener("resize", this.onResize, { passive: true });
4501
+ this.window.addEventListener("pointermove", this.onPointerMove, { passive: true });
4502
+ this.isListening = true;
4503
+ }
4504
+ }
4505
+ detachListeners() {
4506
+ if (this.window?.removeEventListener && this.isListening) {
4507
+ if (this.onResize) this.window.removeEventListener("resize", this.onResize);
4508
+ if (this.onPointerMove) this.window.removeEventListener("pointermove", this.onPointerMove);
4509
+ this.isListening = false;
4510
+ this.hasLastMouse = false;
4511
+ this.wasOutside = false;
4512
+ }
4513
+ }
4514
+ init() {
4515
+ if (!this.setupCanvas()) return false;
4516
+ this.attachListeners();
4517
+ return true;
4518
+ }
4519
+ findClientByTarget(target) {
4520
+ let curr = target && typeof target.nodeType === "number" ? target : null;
4521
+ while (curr) {
4522
+ for (const client of this.activeClients.values()) {
4523
+ if (client.el && client.el === curr) return client;
4524
+ }
4525
+ curr = curr.parentNode;
4526
+ }
4527
+ return null;
4528
+ }
4529
+ findClientByCoords(x, y) {
4530
+ for (const client of this.activeClients.values()) {
4531
+ const rect = client.el?.getBoundingClientRect?.();
4532
+ if (rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return client;
4533
+ }
4534
+ return null;
4535
+ }
4536
+ resolveClientOwner(e) {
4537
+ if (this.activeClients.size === 0) return { options: this.options, el: void 0 };
4538
+ return this.findClientByTarget(e.target) ?? this.findClientByCoords(e.clientX, e.clientY);
4539
+ }
4540
+ getOptionsAt(x, y) {
4541
+ return this.findClientByCoords(x, y)?.options ?? null;
4542
+ }
4543
+ // eslint-disable-next-line max-params -- (x, y, vx, vy, customOpts) is the established drop coordinate and velocity signature
4544
+ spawnDrop(x, y, vx, vy, customOpts) {
4545
+ const activeOpts = customOpts ?? (this.activeClients.size === 0 ? this.options : this.getOptionsAt(x, y));
4546
+ if (!activeOpts) return;
4547
+ this.drops.push({
4548
+ x,
4549
+ y,
4550
+ vx,
4551
+ vy,
4552
+ size: activeOpts.size ?? this.options.size ?? 28,
4553
+ alpha: 0.8,
4554
+ color: activeOpts.color ?? this.options.color ?? "#e4f222",
4555
+ viscosity: activeOpts.viscosity ?? this.options.viscosity ?? 0.88
4556
+ });
4557
+ }
4558
+ startLoop() {
4559
+ if (this.rafId) return;
4560
+ const tick = () => {
4561
+ this.update();
4562
+ this.draw();
4563
+ if (this.drops.length > 0 && this.ctx) {
4564
+ this.rafId = this.raf(tick);
4565
+ } else {
4566
+ this.rafId = null;
4567
+ }
4568
+ };
4569
+ this.rafId = this.raf(tick);
4570
+ }
4571
+ stopLoop() {
4572
+ if (this.rafId) {
4573
+ this.caf(this.rafId);
4574
+ this.rafId = null;
4575
+ }
4576
+ }
4577
+ update() {
4578
+ for (let i = this.drops.length - 1; i >= 0; i--) {
4579
+ const drop = this.drops[i];
4580
+ if (drop && stepFluidDrop(drop, drop.viscosity ?? 0.88)) {
4581
+ this.drops.splice(i, 1);
4582
+ }
4583
+ }
4584
+ }
4585
+ draw() {
4586
+ const ctx = this.ctx;
4587
+ if (!ctx || !this.canvas) return;
4588
+ const dpr = syncCanvasSize(this.canvas, this.window);
4589
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
4590
+ for (const drop of this.drops) {
4591
+ if (typeof ctx.save === "function") ctx.save();
4592
+ ctx.globalAlpha = drop.alpha;
4593
+ ctx.fillStyle = drop.color;
4594
+ ctx.beginPath();
4595
+ ctx.arc(drop.x * dpr, drop.y * dpr, drop.size * dpr * 0.5, 0, Math.PI * 2);
4596
+ ctx.fill();
4597
+ if (typeof ctx.restore === "function") ctx.restore();
4598
+ }
4599
+ }
4600
+ destroy() {
4601
+ this.stopLoop();
4602
+ this.detachListeners();
4603
+ if (this.canvas) {
4604
+ this.canvas.remove();
4605
+ this.canvas = null;
4606
+ }
4607
+ this.ctx = null;
4608
+ this.drops = [];
4609
+ this.refCount = 0;
4610
+ this.clients.clear();
4611
+ this.activeClients.clear();
4612
+ fluidTrails.delete(this.mapKey);
4613
+ }
4614
+ };
4615
+ var fluidTrails = /* @__PURE__ */ new WeakMap();
4616
+ var fallbackFluidKey = {};
4617
+ function getSharedFluidTrail(env) {
4618
+ const resolved = resolveEnv(null, env);
4619
+ const key = resolved.document ?? resolved.window ?? fallbackFluidKey;
4620
+ let trail = fluidTrails.get(key);
4621
+ if (!trail) {
4622
+ trail = new FluidTrail({}, env);
4623
+ fluidTrails.set(key, trail);
4624
+ }
4625
+ return trail;
4626
+ }
4627
+ function prepareFluidCursor(el, params, ctx) {
4628
+ if (isReducedMotion(ctx)) return createInertInstance();
4629
+ const hostDoc = el?.ownerDocument;
4630
+ const resolvedEnv = resolveEnv(ctx, hostDoc ? { document: hostDoc, window: hostDoc.defaultView } : void 0);
4631
+ if (!resolvedEnv.document?.body) {
4632
+ return createInertInstance();
4633
+ }
4634
+ const size = clamp(params.num ? params.num("size", 28) : 28, 4, 120);
4635
+ const viscosity = clamp(params.num ? params.num("viscosity", 0.88) : 0.88, 0.1, 0.99);
4636
+ const color = params.text ? params.text("color", "#e4f222") : "#e4f222";
4637
+ const trail = getSharedFluidTrail(resolvedEnv);
4638
+ const clientId = trail.registerClient({ size, viscosity, color }, el);
4639
+ return createEffectInstance({
4640
+ continuous: true,
4641
+ activate() {
4642
+ trail.activateClient(clientId);
4643
+ },
4644
+ cancel() {
4645
+ trail.deactivateClient(clientId);
4646
+ },
4647
+ finish() {
4648
+ trail.deactivateClient(clientId);
4649
+ },
4650
+ destroy() {
4651
+ trail.unregisterClient(clientId);
4652
+ }
4653
+ });
4654
+ }
4655
+ var FLUID_PARAMETERS = {
4656
+ size: { type: "number", default: "28", minimum: 4, maximum: 120, cssProperty: "--kui-fluid-size" },
4657
+ viscosity: { type: "number", default: "0.88", minimum: 0.1, maximum: 0.99, cssProperty: "--kui-fluid-viscosity" },
4658
+ color: { type: "color", default: "#e4f222", cssProperty: "--kui-fluid-color" }
4659
+ };
4660
+ var FLUID_PRIMITIVES = [
4661
+ {
4662
+ id: "fluid-trail",
4663
+ renderer: "javascript",
4664
+ channels: [],
4665
+ parameters: FLUID_PARAMETERS,
4666
+ supportedTimelines: ["time"],
4667
+ supportedActivations: ["load", "hover"],
4668
+ defaultActivation: "load",
4669
+ perfClass: "continuous",
4670
+ reducedMotion: "disable",
4671
+ prepare: prepareFluidCursor
4672
+ }
4673
+ ];
4674
+ var FLUID_PRESETS = [
4675
+ { name: "fluid-trail", primitive: "fluid-trail" },
4676
+ { name: "cursor-fluid", primitive: "fluid-trail" },
4677
+ { name: "fluid-cursor", primitive: "fluid-trail" }
4678
+ ];
4679
+ function registerFluidCursor(target) {
4680
+ return registerInto(target, FLUID_PRIMITIVES[0], FLUID_PRESETS, "FluidCursor");
4681
+ }
4682
+
4683
+ // src/advanced/index.ts
4684
+ function registerAdvanced(target) {
4685
+ registerShaders(target);
4686
+ registerScenes(target);
4687
+ registerCamera(target);
4688
+ registerParticles(target);
4689
+ registerFluidCursor(target);
4690
+ registerAudio(target);
4691
+ const t = target;
4692
+ return t?.registry || target;
4693
+ }
4694
+ export {
4695
+ AUDIO_PARAMETERS,
4696
+ AUDIO_PRESETS,
4697
+ AUDIO_PRIMITIVES,
4698
+ AudioSourceController,
4699
+ CAMERA_PARAMETERS,
4700
+ CAMERA_PRESETS,
4701
+ CAMERA_PRIMITIVES,
4702
+ FLUID_PARAMETERS,
4703
+ FLUID_PRESETS,
4704
+ FLUID_PRIMITIVES,
4705
+ PARTICLE_PARAMETERS,
4706
+ PARTICLE_PRESETS,
4707
+ PARTICLE_PRIMITIVES,
4708
+ SCENE_PARAMETERS,
4709
+ SCENE_PRESETS,
4710
+ SCENE_PRIMITIVES,
4711
+ SHADERS_PRESETS,
4712
+ SHADERS_PRIMITIVE,
4713
+ SHADER_PARAMETERS,
4714
+ prepareAudioSource,
4715
+ prepareCameraScene,
4716
+ prepareFluidCursor,
4717
+ prepareParticles,
4718
+ prepareScene,
4719
+ prepareShaders,
4720
+ registerAdvanced,
4721
+ registerAudio,
4722
+ registerCamera,
4723
+ registerFluidCursor,
4724
+ registerParticles,
4725
+ registerScenes,
4726
+ registerShaders
4727
+ };