fancy-ui-svelte 0.7.0 → 0.9.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 (58) hide show
  1. package/README.md +107 -81
  2. package/dist/fancy-ui/animated-testimonials/README.md +44 -0
  3. package/dist/fancy-ui/animated-tooltip/README.md +40 -0
  4. package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
  5. package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
  6. package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
  7. package/dist/fancy-ui/border-beam/README.md +37 -0
  8. package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
  9. package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
  10. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
  11. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
  12. package/dist/fancy-ui/displacement-text/README.md +39 -0
  13. package/dist/fancy-ui/dock/README.md +1 -1
  14. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
  15. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
  16. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1250 -1108
  17. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +11 -5
  18. package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +48 -0
  19. package/dist/fancy-ui/fluid-cursor/README.md +57 -6
  20. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +72 -0
  21. package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
  22. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
  23. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
  24. package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
  25. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +36 -0
  26. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +926 -0
  27. package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +5 -3
  28. package/dist/fancy-ui/frosted-glass/README.md +4 -2
  29. package/dist/fancy-ui/index.d.ts +1 -0
  30. package/dist/fancy-ui/index.js +1 -0
  31. package/dist/fancy-ui/line-hover-link/README.md +37 -0
  32. package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
  33. package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
  34. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
  35. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
  36. package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
  37. package/dist/fancy-ui/liquid-glass/README.md +7 -1
  38. package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
  39. package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
  40. package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
  41. package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
  42. package/dist/fancy-ui/liquid-text/README.md +139 -0
  43. package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
  44. package/dist/fancy-ui/liquid-text/index.js +1 -0
  45. package/dist/fancy-ui/marquee/README.md +57 -0
  46. package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
  47. package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
  48. package/dist/fancy-ui/matrix-rain/README.md +42 -0
  49. package/dist/fancy-ui/noise-reveal/README.md +52 -0
  50. package/dist/fancy-ui/rainbow-button/README.md +36 -0
  51. package/dist/fancy-ui/registry.js +144 -20
  52. package/dist/fancy-ui/ripple-button/README.md +36 -0
  53. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
  54. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
  55. package/dist/fancy-ui/terminal-text/README.md +48 -0
  56. package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
  57. package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
  58. package/package.json +18 -4
@@ -0,0 +1,1151 @@
1
+ <script lang="ts" module>
2
+ export interface LiquidTextProps {
3
+ /** Text rendered into the fluid texture (or as static fallback text). */
4
+ text?: string;
5
+ /** CSS font-family. Empty string resolves to getComputedStyle(host).fontFamily. */
6
+ font?: string;
7
+ /** Font size in px. 0 auto-fits the text to the container's width. */
8
+ fontSize?: number;
9
+ /** CSS font-weight for the rasterized/fallback text. */
10
+ fontWeight?: number | string;
11
+ /** Text color used in light mode. */
12
+ lightColor?: string;
13
+ /** Text color used in dark mode. */
14
+ darkColor?: string;
15
+ /** Additional CSS classes applied to the root element. */
16
+ class?: string;
17
+ /** Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. */
18
+ strength?: number;
19
+ /** Splat radius in screen pixels around the pointer. */
20
+ radius?: number;
21
+ /** Multiplier from mouse-delta-per-frame to splat force. */
22
+ forceGain?: number;
23
+ /** Per-frame velocity decay factor (relax-back rate for the smear). */
24
+ dissipation?: number;
25
+ /** Viscous diffusion strength (Jacobi iteration, 8 iters). */
26
+ viscosity?: number;
27
+ /** Chromatic offset = warp amount x this ratio. */
28
+ chromaticRatio?: number;
29
+ /** If window.innerWidth <= staticBelow at mount, render static DOM text instead. */
30
+ staticBelow?: number;
31
+ /** Whether the fluid sim reacts to pointer movement. */
32
+ interactive?: boolean;
33
+ /** Pause the render loop via visibilitychange when the tab/page is hidden. */
34
+ pauseWhenHidden?: boolean;
35
+ }
36
+ </script>
37
+
38
+ <script lang="ts">
39
+ import { cn } from "../../utils.js";
40
+ import { onMount } from "svelte";
41
+
42
+ // ===========================================================================
43
+ // Pure WebGL helpers (stateless — take `gl` as a parameter). Structurally
44
+ // modeled on the FluidCursor pass pipeline (DoubleFBO ping-pong, blit-driven
45
+ // fullscreen passes, Jacobi iteration), rewritten from scratch for a
46
+ // text-displacement sim: velocity-only field (no dye buffer), an added
47
+ // viscous-diffusion pass, and a screen-px splat falloff instead of a
48
+ // gaussian one. See README.md for the full pass order.
49
+ // ===========================================================================
50
+
51
+ type GL = WebGLRenderingContext | WebGL2RenderingContext;
52
+
53
+ interface Fbo {
54
+ texture: WebGLTexture;
55
+ framebuffer: WebGLFramebuffer;
56
+ width: number;
57
+ height: number;
58
+ texelSizeX: number;
59
+ texelSizeY: number;
60
+ attach: (unit: number) => number;
61
+ }
62
+
63
+ interface DoubleFbo {
64
+ width: number;
65
+ height: number;
66
+ texelSizeX: number;
67
+ texelSizeY: number;
68
+ read: Fbo;
69
+ write: Fbo;
70
+ swap: () => void;
71
+ }
72
+
73
+ interface TexFormat {
74
+ internalFormat: number;
75
+ format: number;
76
+ }
77
+
78
+ interface GlExt {
79
+ isWebGL2: boolean;
80
+ halfFloatTexType: number;
81
+ supportLinearFiltering: boolean;
82
+ velocityFormat: TexFormat;
83
+ scalarFormat: TexFormat;
84
+ }
85
+
86
+ function compileShader(gl: GL, type: number, source: string): WebGLShader {
87
+ const shader = gl.createShader(type);
88
+ if (!shader) throw new Error("LiquidText: unable to create shader");
89
+ gl.shaderSource(shader, source);
90
+ gl.compileShader(shader);
91
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
92
+ const info = gl.getShaderInfoLog(shader) ?? "unknown error";
93
+ gl.deleteShader(shader);
94
+ throw new Error(`LiquidText: shader compile failed: ${info}`);
95
+ }
96
+ return shader;
97
+ }
98
+
99
+ function linkProgram(gl: GL, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
100
+ const program = gl.createProgram();
101
+ if (!program) throw new Error("LiquidText: unable to create program");
102
+ gl.attachShader(program, vertexShader);
103
+ gl.attachShader(program, fragmentShader);
104
+ gl.linkProgram(program);
105
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
106
+ const info = gl.getProgramInfoLog(program) ?? "unknown error";
107
+ gl.deleteProgram(program);
108
+ throw new Error(`LiquidText: program link failed: ${info}`);
109
+ }
110
+ return program;
111
+ }
112
+
113
+ function uniformLocations(gl: GL, program: WebGLProgram): Map<string, WebGLUniformLocation> {
114
+ const map = new Map<string, WebGLUniformLocation>();
115
+ const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
116
+ for (let i = 0; i < count; i++) {
117
+ const info = gl.getActiveUniform(program, i);
118
+ if (!info) continue;
119
+ const loc = gl.getUniformLocation(program, info.name);
120
+ if (loc) map.set(info.name, loc);
121
+ }
122
+ return map;
123
+ }
124
+
125
+ class GlProgram {
126
+ readonly program: WebGLProgram;
127
+ readonly uniforms: Map<string, WebGLUniformLocation>;
128
+ private readonly gl: GL;
129
+
130
+ constructor(gl: GL, vertexShader: WebGLShader, fragmentSource: string) {
131
+ this.gl = gl;
132
+ const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
133
+ this.program = linkProgram(gl, vertexShader, fragmentShader);
134
+ gl.deleteShader(fragmentShader);
135
+ this.uniforms = uniformLocations(gl, this.program);
136
+ }
137
+
138
+ use() {
139
+ this.gl.useProgram(this.program);
140
+ }
141
+
142
+ set1f(name: string, value: number) {
143
+ const loc = this.uniforms.get(name);
144
+ if (loc) this.gl.uniform1f(loc, value);
145
+ }
146
+
147
+ set2f(name: string, x: number, y: number) {
148
+ const loc = this.uniforms.get(name);
149
+ if (loc) this.gl.uniform2f(loc, x, y);
150
+ }
151
+
152
+ set1i(name: string, value: number) {
153
+ const loc = this.uniforms.get(name);
154
+ if (loc) this.gl.uniform1i(loc, value);
155
+ }
156
+
157
+ dispose() {
158
+ this.gl.deleteProgram(this.program);
159
+ }
160
+ }
161
+
162
+ function createFbo(
163
+ gl: GL,
164
+ width: number,
165
+ height: number,
166
+ fmt: TexFormat,
167
+ type: number,
168
+ filter: number
169
+ ): Fbo {
170
+ gl.activeTexture(gl.TEXTURE0);
171
+ const texture = gl.createTexture();
172
+ if (!texture) throw new Error("LiquidText: unable to allocate texture");
173
+ gl.bindTexture(gl.TEXTURE_2D, texture);
174
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
175
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
176
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
177
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
178
+ gl.texImage2D(gl.TEXTURE_2D, 0, fmt.internalFormat, width, height, 0, fmt.format, type, null);
179
+
180
+ const framebuffer = gl.createFramebuffer();
181
+ if (!framebuffer) throw new Error("LiquidText: unable to allocate framebuffer");
182
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
183
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
184
+ gl.viewport(0, 0, width, height);
185
+ gl.clearColor(0, 0, 0, 0);
186
+ gl.clear(gl.COLOR_BUFFER_BIT);
187
+
188
+ return {
189
+ texture,
190
+ framebuffer,
191
+ width,
192
+ height,
193
+ texelSizeX: 1 / width,
194
+ texelSizeY: 1 / height,
195
+ attach: (unit: number) => {
196
+ gl.activeTexture(gl.TEXTURE0 + unit);
197
+ gl.bindTexture(gl.TEXTURE_2D, texture);
198
+ return unit;
199
+ },
200
+ };
201
+ }
202
+
203
+ function deleteFbo(gl: GL, fbo: Fbo | null | undefined) {
204
+ if (!fbo) return;
205
+ gl.deleteTexture(fbo.texture);
206
+ gl.deleteFramebuffer(fbo.framebuffer);
207
+ }
208
+
209
+ function createDoubleFbo(
210
+ gl: GL,
211
+ width: number,
212
+ height: number,
213
+ fmt: TexFormat,
214
+ type: number,
215
+ filter: number
216
+ ): DoubleFbo {
217
+ let read = createFbo(gl, width, height, fmt, type, filter);
218
+ let write = createFbo(gl, width, height, fmt, type, filter);
219
+ return {
220
+ width,
221
+ height,
222
+ texelSizeX: read.texelSizeX,
223
+ texelSizeY: read.texelSizeY,
224
+ get read() {
225
+ return read;
226
+ },
227
+ get write() {
228
+ return write;
229
+ },
230
+ swap() {
231
+ const tmp = read;
232
+ read = write;
233
+ write = tmp;
234
+ },
235
+ };
236
+ }
237
+
238
+ function deleteDoubleFbo(gl: GL, dfbo: DoubleFbo | null | undefined) {
239
+ if (!dfbo) return;
240
+ deleteFbo(gl, dfbo.read);
241
+ deleteFbo(gl, dfbo.write);
242
+ }
243
+
244
+ function textureRenderable(gl: GL, internalFormat: number, format: number, type: number): boolean {
245
+ const texture = gl.createTexture();
246
+ if (!texture) return false;
247
+ gl.bindTexture(gl.TEXTURE_2D, texture);
248
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
249
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
250
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
251
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
252
+ gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);
253
+
254
+ const framebuffer = gl.createFramebuffer();
255
+ let ok = false;
256
+ if (framebuffer) {
257
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
258
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
259
+ ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
260
+ gl.deleteFramebuffer(framebuffer);
261
+ }
262
+ gl.deleteTexture(texture);
263
+ return ok;
264
+ }
265
+
266
+ function resolveFormat(
267
+ gl: GL,
268
+ isWebGL2: boolean,
269
+ kind: "velocity" | "scalar",
270
+ type: number
271
+ ): TexFormat | null {
272
+ if (!isWebGL2) {
273
+ return textureRenderable(gl, gl.RGBA, gl.RGBA, type)
274
+ ? { internalFormat: gl.RGBA, format: gl.RGBA }
275
+ : null;
276
+ }
277
+ const gl2 = gl as WebGL2RenderingContext;
278
+ const candidates: TexFormat[] =
279
+ kind === "velocity"
280
+ ? [
281
+ { internalFormat: gl2.RG16F, format: gl2.RG },
282
+ { internalFormat: gl2.RGBA16F, format: gl2.RGBA },
283
+ ]
284
+ : [
285
+ { internalFormat: gl2.R16F, format: gl2.RED },
286
+ { internalFormat: gl2.RG16F, format: gl2.RG },
287
+ { internalFormat: gl2.RGBA16F, format: gl2.RGBA },
288
+ ];
289
+ for (const candidate of candidates) {
290
+ if (textureRenderable(gl, candidate.internalFormat, candidate.format, type)) return candidate;
291
+ }
292
+ return null;
293
+ }
294
+
295
+ function acquireGL(canvas: HTMLCanvasElement): { gl: GL; ext: GlExt } | null {
296
+ const attrs: WebGLContextAttributes = {
297
+ alpha: true,
298
+ depth: false,
299
+ stencil: false,
300
+ antialias: false,
301
+ preserveDrawingBuffer: false,
302
+ };
303
+
304
+ let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
305
+ let isWebGL2 = !!gl;
306
+ if (!gl) {
307
+ gl = (canvas.getContext("webgl", attrs) ??
308
+ canvas.getContext("experimental-webgl", attrs)) as WebGLRenderingContext | null;
309
+ isWebGL2 = false;
310
+ }
311
+ if (!gl) return null;
312
+
313
+ let halfFloatTexType: number;
314
+ let supportLinearFiltering: boolean;
315
+
316
+ if (isWebGL2) {
317
+ const gl2 = gl as WebGL2RenderingContext;
318
+ gl2.getExtension("EXT_color_buffer_float");
319
+ // This component only ever allocates HALF_FLOAT (RG16F/R16F/RGBA16F)
320
+ // textures, and 16-bit float textures are always texture-filterable
321
+ // in core WebGL2 — OES_texture_float_linear only gates 32-bit FLOAT
322
+ // textures, which never get allocated here. Gating on that
323
+ // extension made the velocity FBO fall back to NEAREST (and the
324
+ // 0.25x sim field get upsampled blocky by the display shader) on
325
+ // the many GPUs/ANGLE backends that expose half-float without
326
+ // float-linear.
327
+ supportLinearFiltering = true;
328
+ halfFloatTexType = gl2.HALF_FLOAT;
329
+ } else {
330
+ // Some WebGL1 drivers require this to make a half-float texture
331
+ // usable as an FBO color attachment; without it, textureRenderable
332
+ // below fails every candidate and the component drops to the
333
+ // static fallback even though the sim could have run.
334
+ gl.getExtension("EXT_color_buffer_half_float");
335
+ const halfFloat = gl.getExtension("OES_texture_half_float");
336
+ supportLinearFiltering = !!gl.getExtension("OES_texture_half_float_linear");
337
+ halfFloatTexType = halfFloat ? halfFloat.HALF_FLOAT_OES : 0;
338
+ }
339
+
340
+ const velocityFormat = resolveFormat(gl, isWebGL2, "velocity", halfFloatTexType);
341
+ const scalarFormat = resolveFormat(gl, isWebGL2, "scalar", halfFloatTexType);
342
+ if (!velocityFormat || !scalarFormat) return null;
343
+
344
+ return { gl, ext: { isWebGL2, halfFloatTexType, supportLinearFiltering, velocityFormat, scalarFormat } };
345
+ }
346
+
347
+ function createBlit(gl: GL) {
348
+ const vertexBuffer = gl.createBuffer();
349
+ const indexBuffer = gl.createBuffer();
350
+ if (!vertexBuffer || !indexBuffer) throw new Error("LiquidText: unable to allocate geometry buffers");
351
+
352
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
353
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
354
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
355
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
356
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
357
+ gl.enableVertexAttribArray(0);
358
+
359
+ function blit(target: Fbo | null) {
360
+ if (target) {
361
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
362
+ gl.viewport(0, 0, target.width, target.height);
363
+ } else {
364
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
365
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
366
+ }
367
+ gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
368
+ }
369
+
370
+ function dispose() {
371
+ gl.deleteBuffer(vertexBuffer);
372
+ gl.deleteBuffer(indexBuffer);
373
+ }
374
+
375
+ return { blit, dispose };
376
+ }
377
+
378
+ // ===========================================================================
379
+ // Shader sources
380
+ // ===========================================================================
381
+
382
+ const VERTEX_SHADER = `
383
+ precision highp float;
384
+ attribute vec2 aPos;
385
+ varying vec2 vUv;
386
+ varying vec2 vL;
387
+ varying vec2 vR;
388
+ varying vec2 vT;
389
+ varying vec2 vB;
390
+ uniform vec2 texelSize;
391
+
392
+ void main () {
393
+ vUv = aPos * 0.5 + 0.5;
394
+ vL = vUv - vec2(texelSize.x, 0.0);
395
+ vR = vUv + vec2(texelSize.x, 0.0);
396
+ vT = vUv + vec2(0.0, texelSize.y);
397
+ vB = vUv - vec2(0.0, texelSize.y);
398
+ gl_Position = vec4(aPos, 0.0, 1.0);
399
+ }
400
+ `;
401
+
402
+ const COPY_SHADER = `
403
+ precision mediump float;
404
+ precision mediump sampler2D;
405
+ varying vec2 vUv;
406
+ uniform sampler2D uTexture;
407
+ void main () {
408
+ gl_FragColor = texture2D(uTexture, vUv);
409
+ }
410
+ `;
411
+
412
+ // Self-advects the velocity field along its own flow, then applies the
413
+ // per-frame dissipation decay (a plain multiplicative decay — not a
414
+ // dt-scaled rate — so `dissipation` reads directly as "fraction of
415
+ // velocity kept per frame").
416
+ const ADVECTION_SHADER = `
417
+ precision highp float;
418
+ precision highp sampler2D;
419
+ varying vec2 vUv;
420
+ uniform sampler2D uVelocity;
421
+ uniform float dt;
422
+ uniform float dissipation;
423
+
424
+ void main () {
425
+ vec2 back = vUv - dt * texture2D(uVelocity, vUv).xy;
426
+ vec2 advected = texture2D(uVelocity, back).xy;
427
+ gl_FragColor = vec4(clamp(advected * dissipation, -4.0, 4.0), 0.0, 1.0);
428
+ }
429
+ `;
430
+
431
+ // Injects pointer force at `point` (UV) with a screen-px radius falloff:
432
+ // (1 - clamp(dist/radius, 0, 1))^2 * 1.5, dist measured in real screen px
433
+ // via `resolution` (the container's CSS size), independent of the sim's
434
+ // own (lower) texel resolution.
435
+ const SPLAT_SHADER = `
436
+ precision highp float;
437
+ precision highp sampler2D;
438
+ varying vec2 vUv;
439
+ uniform sampler2D uVelocity;
440
+ uniform vec2 point;
441
+ uniform vec2 resolution;
442
+ uniform float radius;
443
+ uniform vec2 force;
444
+
445
+ void main () {
446
+ vec2 diffPx = (vUv - point) * resolution;
447
+ float dist = length(diffPx);
448
+ float t = clamp(1.0 - dist / max(radius, 1.0), 0.0, 1.0);
449
+ float falloff = t * t * 1.5;
450
+ vec2 base = texture2D(uVelocity, vUv).xy;
451
+ gl_FragColor = vec4(clamp(base + force * falloff, -4.0, 4.0), 0.0, 1.0);
452
+ }
453
+ `;
454
+
455
+ // One Jacobi iteration of implicit viscous diffusion:
456
+ // x_new = (x0 + alpha*(xL+xR+xT+xB)) / (1 + 4*alpha), alpha = viscosity*dt.
457
+ // `uSource` is the fixed pre-diffusion snapshot (x0); `uVelocity` is the
458
+ // current iterate, ping-ponged across 8 calls.
459
+ const DIFFUSE_SHADER = `
460
+ precision highp float;
461
+ precision highp sampler2D;
462
+ varying vec2 vUv;
463
+ varying vec2 vL;
464
+ varying vec2 vR;
465
+ varying vec2 vT;
466
+ varying vec2 vB;
467
+ uniform sampler2D uVelocity;
468
+ uniform sampler2D uSource;
469
+ uniform float alpha;
470
+
471
+ void main () {
472
+ vec2 xL = texture2D(uVelocity, vL).xy;
473
+ vec2 xR = texture2D(uVelocity, vR).xy;
474
+ vec2 xT = texture2D(uVelocity, vT).xy;
475
+ vec2 xB = texture2D(uVelocity, vB).xy;
476
+ vec2 x0 = texture2D(uSource, vUv).xy;
477
+ float beta = 1.0 + 4.0 * alpha;
478
+ vec2 result = (x0 + alpha * (xL + xR + xT + xB)) / beta;
479
+ gl_FragColor = vec4(clamp(result, -4.0, 4.0), 0.0, 1.0);
480
+ }
481
+ `;
482
+
483
+ const DIVERGENCE_SHADER = `
484
+ precision highp float;
485
+ precision highp sampler2D;
486
+ varying vec2 vUv;
487
+ varying vec2 vL;
488
+ varying vec2 vR;
489
+ varying vec2 vT;
490
+ varying vec2 vB;
491
+ uniform sampler2D uVelocity;
492
+
493
+ void main () {
494
+ float L = texture2D(uVelocity, vL).x;
495
+ float R = texture2D(uVelocity, vR).x;
496
+ float T = texture2D(uVelocity, vT).y;
497
+ float B = texture2D(uVelocity, vB).y;
498
+ vec2 C = texture2D(uVelocity, vUv).xy;
499
+ if (vL.x < 0.0) L = -C.x;
500
+ if (vR.x > 1.0) R = -C.x;
501
+ if (vT.y > 1.0) T = -C.y;
502
+ if (vB.y < 0.0) B = -C.y;
503
+ float div = 0.5 * (R - L + T - B);
504
+ gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
505
+ }
506
+ `;
507
+
508
+ const PRESSURE_SHADER = `
509
+ precision highp float;
510
+ precision highp sampler2D;
511
+ varying vec2 vUv;
512
+ varying vec2 vL;
513
+ varying vec2 vR;
514
+ varying vec2 vT;
515
+ varying vec2 vB;
516
+ uniform sampler2D uPressure;
517
+ uniform sampler2D uDivergence;
518
+
519
+ void main () {
520
+ float L = texture2D(uPressure, vL).x;
521
+ float R = texture2D(uPressure, vR).x;
522
+ float T = texture2D(uPressure, vT).x;
523
+ float B = texture2D(uPressure, vB).x;
524
+ float div = texture2D(uDivergence, vUv).x;
525
+ float p = (L + R + T + B - div) * 0.25;
526
+ gl_FragColor = vec4(p, 0.0, 0.0, 1.0);
527
+ }
528
+ `;
529
+
530
+ const GRADIENT_SUBTRACT_SHADER = `
531
+ precision highp float;
532
+ precision highp sampler2D;
533
+ varying vec2 vUv;
534
+ varying vec2 vL;
535
+ varying vec2 vR;
536
+ varying vec2 vT;
537
+ varying vec2 vB;
538
+ uniform sampler2D uPressure;
539
+ uniform sampler2D uVelocity;
540
+
541
+ void main () {
542
+ float L = texture2D(uPressure, vL).x;
543
+ float R = texture2D(uPressure, vR).x;
544
+ float T = texture2D(uPressure, vT).x;
545
+ float B = texture2D(uPressure, vB).x;
546
+ vec2 vel = texture2D(uVelocity, vUv).xy;
547
+ vel -= 0.5 * vec2(R - L, T - B);
548
+ gl_FragColor = vec4(clamp(vel, -4.0, 4.0), 0.0, 1.0);
549
+ }
550
+ `;
551
+
552
+ // Final composite: warps the rasterized-text UV by the velocity field and
553
+ // takes 3 independently-offset taps (R/G/B) for the chromatic-aberration
554
+ // fringe. Alpha is the max of the 3 taps' alpha so the fringe itself is
555
+ // visible over the transparent background rather than being clipped to the
556
+ // unwarped glyph silhouette.
557
+ const DISPLAY_SHADER = `
558
+ precision highp float;
559
+ precision highp sampler2D;
560
+ varying vec2 vUv;
561
+ uniform sampler2D uText;
562
+ uniform sampler2D uVelocity;
563
+ uniform float strength;
564
+ uniform float chromaticRatio;
565
+
566
+ void main () {
567
+ vec2 vel = texture2D(uVelocity, vUv).xy;
568
+ vec2 warp = vel * strength;
569
+ vec2 chroma = warp * chromaticRatio;
570
+ vec2 base = vUv - warp;
571
+ vec4 rTap = texture2D(uText, base - chroma);
572
+ vec4 gTap = texture2D(uText, base);
573
+ vec4 bTap = texture2D(uText, base + chroma);
574
+ vec3 color = vec3(rTap.r, gTap.g, bTap.b);
575
+ float a = max(rTap.a, max(gTap.a, bTap.a));
576
+ gl_FragColor = vec4(color, a);
577
+ }
578
+ `;
579
+
580
+ const FIXED_DT = 0.016;
581
+ const DIFFUSE_ITERATIONS = 8;
582
+ const PRESSURE_ITERATIONS = 16;
583
+ const SIM_SCALE = 0.25;
584
+
585
+ // ===========================================================================
586
+ // Component
587
+ // ===========================================================================
588
+
589
+ let {
590
+ text = "Liquid",
591
+ font = "",
592
+ fontSize = 0,
593
+ fontWeight = 700,
594
+ lightColor = "#000000",
595
+ darkColor = "#ffffff",
596
+ class: className = "",
597
+ strength = 0.5,
598
+ radius = 160,
599
+ forceGain = 17,
600
+ dissipation = 0.98,
601
+ viscosity = 4,
602
+ chromaticRatio = 0.2,
603
+ staticBelow = 1024,
604
+ interactive = true,
605
+ pauseWhenHidden = true,
606
+ }: LiquidTextProps = $props();
607
+
608
+ let rootEl: HTMLDivElement | undefined = $state();
609
+ let canvasEl: HTMLCanvasElement | undefined = $state();
610
+
611
+ // Always starts "static" so the very first render (SSR or client) never
612
+ // touches window/document — onMount promotes to "canvas" if eligible.
613
+ let mode: "static" | "canvas" = $state("static");
614
+ let themeColor = $state(lightColor);
615
+ let resolvedFont = $state(font);
616
+ let displayFontSize = $state(fontSize > 0 ? fontSize : 64);
617
+
618
+ // Hoisted so the prop-reactivity effect below can push a re-rasterize
619
+ // into the live WebGL texture after mount. Assigned by setupCanvasSim
620
+ // once the sim is running; stays null on the static-fallback path, where
621
+ // updating the $state above is enough since the fallback is just
622
+ // reactive DOM markup.
623
+ let rasterize: (() => void) | null = null;
624
+
625
+ const rootHeight = $derived(`${Math.round(displayFontSize * 1.2)}px`);
626
+
627
+ function activeThemeColor(): string {
628
+ return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
629
+ ? darkColor
630
+ : lightColor;
631
+ }
632
+
633
+ // Recomputes resolvedFont + displayFontSize together (using a local
634
+ // `family` const rather than re-reading the `resolvedFont` state it just
635
+ // wrote) so this can safely run inside the reactive $effect below without
636
+ // the effect both writing and re-reading the same piece of state in one
637
+ // pass.
638
+ function fitFontSize() {
639
+ if (!rootEl) return;
640
+ const family = font || getComputedStyle(rootEl).fontFamily || "sans-serif";
641
+ resolvedFont = family;
642
+ if (fontSize > 0) {
643
+ displayFontSize = fontSize;
644
+ return;
645
+ }
646
+ const containerWidth = rootEl.clientWidth || 1;
647
+ const probe = document.createElement("canvas").getContext("2d");
648
+ if (!probe) return;
649
+ const reference = 100;
650
+ probe.font = `${fontWeight} ${reference}px ${family}`;
651
+ const measured = probe.measureText(text).width || 1;
652
+ const fitted = reference * (containerWidth / measured);
653
+ displayFontSize = Math.min(Math.max(fitted, 8), 2000);
654
+ }
655
+
656
+ // Per the "text/font changes re-rasterize" requirement: the WebGL path
657
+ // otherwise only reads text/font/fontSize/fontWeight/lightColor/
658
+ // darkColor once, at setup, so updating them in e.g. a Storybook/docs
659
+ // control would silently do nothing. Reading the props directly here
660
+ // (rather than only inside the helpers above) registers them as this
661
+ // effect's dependencies, so any of them changing after mount re-derives
662
+ // the resolved font/size/color and, on the canvas path, re-rasterizes.
663
+ $effect(() => {
664
+ void text;
665
+ void font;
666
+ void fontSize;
667
+ void fontWeight;
668
+ void lightColor;
669
+ void darkColor;
670
+ if (!rootEl) return;
671
+ themeColor = activeThemeColor();
672
+ fitFontSize();
673
+ rasterize?.();
674
+ });
675
+
676
+ onMount(() => {
677
+ const root = rootEl;
678
+ const canvas = canvasEl;
679
+ if (!root || !canvas) return;
680
+
681
+ themeColor = activeThemeColor();
682
+
683
+ const themeObserver = new MutationObserver(() => {
684
+ const next = activeThemeColor();
685
+ if (next !== themeColor) {
686
+ themeColor = next;
687
+ rasterize?.();
688
+ }
689
+ });
690
+ themeObserver.observe(document.documentElement, {
691
+ attributes: true,
692
+ attributeFilter: ["class"],
693
+ });
694
+
695
+ fitFontSize();
696
+
697
+ const reduceMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
698
+ const tooSmall = window.innerWidth <= staticBelow;
699
+
700
+ let cleanupCanvasSim: (() => void) | null = null;
701
+ let fallbackResizeObserver: ResizeObserver | null = null;
702
+
703
+ // setupCanvasSim installs its own ResizeObserver; the static fallback
704
+ // (reduced-motion, staticBelow, no WebGL, a failed GL init, or a
705
+ // mid-session GPU context loss — see onContextLost inside
706
+ // setupCanvasSim) has no such observer of its own, so without this the
707
+ // auto-fit (fontSize=0) fallback text would stay frozen at its
708
+ // mount-time size across any later container/viewport resize (e.g.
709
+ // mobile rotation). Guarded against re-entry so it's safe to call
710
+ // again (e.g. if a restored context is lost a second time) without
711
+ // leaking a duplicate observer.
712
+ function startFallbackResize() {
713
+ if (!root || fallbackResizeObserver) return;
714
+ fallbackResizeObserver = new ResizeObserver(() => fitFontSize());
715
+ fallbackResizeObserver.observe(root);
716
+ }
717
+
718
+ if (!reduceMotionQuery.matches && !tooSmall) {
719
+ cleanupCanvasSim = setupCanvasSim(canvas, root, startFallbackResize);
720
+ }
721
+ if (!cleanupCanvasSim) {
722
+ startFallbackResize();
723
+ }
724
+
725
+ // Honor a live toggle of the OS reduced-motion setting, not just its
726
+ // value at mount: if it flips to "reduce" while the sim is running,
727
+ // tear the sim down and drop to the static fallback.
728
+ function onReduceMotionChange(e: MediaQueryListEvent) {
729
+ if (e.matches && cleanupCanvasSim) {
730
+ cleanupCanvasSim();
731
+ cleanupCanvasSim = null;
732
+ mode = "static";
733
+ startFallbackResize();
734
+ }
735
+ }
736
+ reduceMotionQuery.addEventListener("change", onReduceMotionChange);
737
+
738
+ return () => {
739
+ themeObserver.disconnect();
740
+ reduceMotionQuery.removeEventListener("change", onReduceMotionChange);
741
+ fallbackResizeObserver?.disconnect();
742
+ cleanupCanvasSim?.();
743
+ };
744
+ });
745
+
746
+ /**
747
+ * Attempts to acquire a WebGL context and, if successful, boots the fluid
748
+ * sim + render loop and switches `mode` to "canvas". Returns a cleanup
749
+ * function on success, or `null` if WebGL is unavailable (caller should
750
+ * keep the static DOM fallback).
751
+ *
752
+ * `startFallbackResize` is onMount's fallback-resize starter, threaded
753
+ * through so the context-loss handler below can re-arm it when it drops
754
+ * the sim to the static fallback mid-session — it closes over onMount's
755
+ * `root`/`fallbackResizeObserver`, so calling it here keeps that observer
756
+ * reachable from onMount's own cleanup return, and it's a no-op if the
757
+ * observer is already attached.
758
+ */
759
+ function setupCanvasSim(
760
+ canvas: HTMLCanvasElement,
761
+ root: HTMLDivElement,
762
+ startFallbackResize: () => void
763
+ ): (() => void) | null {
764
+ const acquired = acquireGL(canvas);
765
+ if (!acquired) return null;
766
+
767
+ const { gl, ext } = acquired;
768
+
769
+ // Declared here (outside the try below) so both the success path and
770
+ // the failure-recovery catch block can safely reference them — `let`/
771
+ // `function` declared inside a `try { … }` are not visible from its
772
+ // `catch { … }`, since try/catch are separate block scopes.
773
+ let velocity: DoubleFbo | null = null;
774
+ let divergenceFbo: Fbo | null = null;
775
+ let pressure: DoubleFbo | null = null;
776
+ let diffuseSource: Fbo | null = null;
777
+ let textTexture: WebGLTexture | null = null;
778
+ let textCanvas: HTMLCanvasElement | null = null;
779
+
780
+ function destroySimBuffers() {
781
+ deleteDoubleFbo(gl, velocity);
782
+ deleteFbo(gl, divergenceFbo);
783
+ deleteDoubleFbo(gl, pressure);
784
+ deleteFbo(gl, diffuseSource);
785
+ velocity = null;
786
+ divergenceFbo = null;
787
+ pressure = null;
788
+ diffuseSource = null;
789
+ }
790
+
791
+ // Tracks GL resources as they're successfully allocated during this
792
+ // synchronous setup pass, so a later failure (a program that fails to
793
+ // compile/link, exhausted buffers, …) can free everything created
794
+ // before it instead of stranding those objects on the canvas's
795
+ // still-live GL context. Cleared once setup fully succeeds.
796
+ const disposeOnFailure: Array<() => void> = [];
797
+
798
+ try {
799
+ const { blit, dispose: disposeGeometry } = createBlit(gl);
800
+ disposeOnFailure.push(disposeGeometry);
801
+
802
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
803
+ disposeOnFailure.push(() => gl.deleteShader(vertexShader));
804
+
805
+ function makeProgram(fragmentSource: string): GlProgram {
806
+ const program = new GlProgram(gl, vertexShader, fragmentSource);
807
+ disposeOnFailure.push(() => program.dispose());
808
+ return program;
809
+ }
810
+
811
+ const advectionProgram = makeProgram(ADVECTION_SHADER);
812
+ const splatProgram = makeProgram(SPLAT_SHADER);
813
+ const diffuseProgram = makeProgram(DIFFUSE_SHADER);
814
+ const divergenceProgram = makeProgram(DIVERGENCE_SHADER);
815
+ const pressureProgram = makeProgram(PRESSURE_SHADER);
816
+ const gradientSubtractProgram = makeProgram(GRADIENT_SUBTRACT_SHADER);
817
+ const copyProgram = makeProgram(COPY_SHADER);
818
+ const displayProgram = makeProgram(DISPLAY_SHADER);
819
+ gl.deleteShader(vertexShader);
820
+
821
+ const filter = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
822
+
823
+ let cssWidth = 0;
824
+ let cssHeight = 0;
825
+
826
+ function createSimBuffers(simW: number, simH: number) {
827
+ velocity = createDoubleFbo(gl, simW, simH, ext.velocityFormat, ext.halfFloatTexType, filter);
828
+ divergenceFbo = createFbo(gl, simW, simH, ext.scalarFormat, ext.halfFloatTexType, gl.NEAREST);
829
+ pressure = createDoubleFbo(gl, simW, simH, ext.scalarFormat, ext.halfFloatTexType, gl.NEAREST);
830
+ diffuseSource = createFbo(gl, simW, simH, ext.velocityFormat, ext.halfFloatTexType, filter);
831
+ }
832
+
833
+ function rasterizeText() {
834
+ const dpr = window.devicePixelRatio || 1;
835
+ const w = Math.max(1, Math.round(cssWidth * dpr));
836
+ const h = Math.max(1, Math.round(cssHeight * dpr));
837
+ if (!textCanvas) textCanvas = document.createElement("canvas");
838
+ textCanvas.width = w;
839
+ textCanvas.height = h;
840
+ const ctx = textCanvas.getContext("2d");
841
+ if (!ctx) return;
842
+ ctx.clearRect(0, 0, w, h);
843
+ ctx.font = `${fontWeight} ${displayFontSize * dpr}px ${resolvedFont}`;
844
+ ctx.fillStyle = themeColor;
845
+ ctx.textAlign = "left";
846
+ ctx.textBaseline = "middle";
847
+ ctx.fillText(text, 0, h / 2);
848
+
849
+ if (!textTexture) textTexture = gl.createTexture();
850
+ gl.bindTexture(gl.TEXTURE_2D, textTexture);
851
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
852
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
853
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
854
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
855
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
856
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
857
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
858
+ }
859
+
860
+ function resizeAll() {
861
+ fitFontSize();
862
+ const dpr = window.devicePixelRatio || 1;
863
+ cssWidth = root.clientWidth || 1;
864
+ cssHeight = Math.round(displayFontSize * 1.2) || 1;
865
+ const pxW = Math.max(1, Math.round(cssWidth * dpr));
866
+ const pxH = Math.max(1, Math.round(cssHeight * dpr));
867
+ if (canvas.width !== pxW || canvas.height !== pxH) {
868
+ canvas.width = pxW;
869
+ canvas.height = pxH;
870
+ }
871
+ const simW = Math.max(1, Math.round(pxW * SIM_SCALE));
872
+ const simH = Math.max(1, Math.round(pxH * SIM_SCALE));
873
+ if (!velocity || velocity.width !== simW || velocity.height !== simH) {
874
+ destroySimBuffers();
875
+ createSimBuffers(simW, simH);
876
+ }
877
+ rasterizeText();
878
+ }
879
+
880
+ resizeAll();
881
+ mode = "canvas";
882
+ rasterize = rasterizeText;
883
+
884
+ const resizeObserver = new ResizeObserver(() => resizeAll());
885
+ resizeObserver.observe(root);
886
+
887
+ // --- pointer tracking (UV space, y=1 at top to match vUv) ---
888
+ const pointerUV = { x: 0.5, y: 0.5 };
889
+ const pointerPrevUV = { x: 0.5, y: 0.5 };
890
+ const pointerForce = { x: 0, y: 0 };
891
+ let pointerHasPrev = false;
892
+ let pointerMoved = false;
893
+
894
+ function onPointerMove(e: PointerEvent) {
895
+ if (!interactive) return;
896
+ const rect = root.getBoundingClientRect();
897
+ if (rect.width <= 0 || rect.height <= 0) return;
898
+ const x = (e.clientX - rect.left) / rect.width;
899
+ const y = 1 - (e.clientY - rect.top) / rect.height;
900
+ if (pointerHasPrev) {
901
+ pointerForce.x = (x - pointerPrevUV.x) * forceGain;
902
+ pointerForce.y = (y - pointerPrevUV.y) * forceGain;
903
+ pointerMoved = true;
904
+ }
905
+ pointerPrevUV.x = x;
906
+ pointerPrevUV.y = y;
907
+ pointerUV.x = x;
908
+ pointerUV.y = y;
909
+ pointerHasPrev = true;
910
+ }
911
+
912
+ // Always registered — `interactive` is read live inside
913
+ // onPointerMove above, so toggling the prop after mount (e.g. a
914
+ // Storybook/docs control) takes effect immediately instead of
915
+ // only reflecting its value at initial setup.
916
+ window.addEventListener("pointermove", onPointerMove, { passive: true });
917
+
918
+ // --- visibility pause ---
919
+ let hidden = document.hidden;
920
+ function onVisibilityChange() {
921
+ hidden = document.hidden;
922
+ }
923
+ // Always registered — `pauseWhenHidden` is read live in frame()
924
+ // below, so toggling the prop after mount takes effect immediately
925
+ // instead of only reflecting its value at initial setup.
926
+ document.addEventListener("visibilitychange", onVisibilityChange);
927
+
928
+ // --- sim passes ---
929
+ function applySplat() {
930
+ if (!pointerMoved || !velocity) return;
931
+ pointerMoved = false;
932
+ splatProgram.use();
933
+ splatProgram.set2f("point", pointerUV.x, pointerUV.y);
934
+ splatProgram.set2f("resolution", cssWidth, cssHeight);
935
+ splatProgram.set1f("radius", radius);
936
+ splatProgram.set2f("force", pointerForce.x, pointerForce.y);
937
+ splatProgram.set1i("uVelocity", velocity.read.attach(0));
938
+ blit(velocity.write);
939
+ velocity.swap();
940
+ }
941
+
942
+ function diffuseVelocity(dt: number) {
943
+ if (!velocity || !diffuseSource) return;
944
+ copyProgram.use();
945
+ copyProgram.set1i("uTexture", velocity.read.attach(0));
946
+ blit(diffuseSource);
947
+
948
+ const alpha = viscosity * dt;
949
+ for (let i = 0; i < DIFFUSE_ITERATIONS; i++) {
950
+ diffuseProgram.use();
951
+ diffuseProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
952
+ diffuseProgram.set1i("uVelocity", velocity.read.attach(0));
953
+ diffuseProgram.set1i("uSource", diffuseSource.attach(1));
954
+ diffuseProgram.set1f("alpha", alpha);
955
+ blit(velocity.write);
956
+ velocity.swap();
957
+ }
958
+ }
959
+
960
+ function clearFbo(fbo: Fbo) {
961
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.framebuffer);
962
+ gl.viewport(0, 0, fbo.width, fbo.height);
963
+ gl.clearColor(0, 0, 0, 0);
964
+ gl.clear(gl.COLOR_BUFFER_BIT);
965
+ }
966
+
967
+ function step(dt: number) {
968
+ if (!velocity || !divergenceFbo || !pressure) return;
969
+ gl.disable(gl.BLEND);
970
+
971
+ advectionProgram.use();
972
+ advectionProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
973
+ advectionProgram.set1i("uVelocity", velocity.read.attach(0));
974
+ advectionProgram.set1f("dt", dt);
975
+ advectionProgram.set1f("dissipation", dissipation);
976
+ blit(velocity.write);
977
+ velocity.swap();
978
+
979
+ applySplat();
980
+ diffuseVelocity(dt);
981
+
982
+ divergenceProgram.use();
983
+ divergenceProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
984
+ divergenceProgram.set1i("uVelocity", velocity.read.attach(0));
985
+ blit(divergenceFbo);
986
+
987
+ clearFbo(pressure.write);
988
+ pressure.swap();
989
+ for (let i = 0; i < PRESSURE_ITERATIONS; i++) {
990
+ pressureProgram.use();
991
+ pressureProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
992
+ pressureProgram.set1i("uPressure", pressure.read.attach(0));
993
+ pressureProgram.set1i("uDivergence", divergenceFbo.attach(1));
994
+ blit(pressure.write);
995
+ pressure.swap();
996
+ }
997
+
998
+ gradientSubtractProgram.use();
999
+ gradientSubtractProgram.set2f("texelSize", velocity.texelSizeX, velocity.texelSizeY);
1000
+ gradientSubtractProgram.set1i("uPressure", pressure.read.attach(0));
1001
+ gradientSubtractProgram.set1i("uVelocity", velocity.read.attach(1));
1002
+ blit(velocity.write);
1003
+ velocity.swap();
1004
+ }
1005
+
1006
+ function draw() {
1007
+ if (!velocity || !textTexture) return;
1008
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1009
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
1010
+ gl.clearColor(0, 0, 0, 0);
1011
+ gl.clear(gl.COLOR_BUFFER_BIT);
1012
+ gl.enable(gl.BLEND);
1013
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1014
+
1015
+ displayProgram.use();
1016
+ gl.activeTexture(gl.TEXTURE0);
1017
+ gl.bindTexture(gl.TEXTURE_2D, textTexture);
1018
+ displayProgram.set1i("uText", 0);
1019
+ displayProgram.set1i("uVelocity", velocity.read.attach(1));
1020
+ displayProgram.set1f("strength", strength);
1021
+ displayProgram.set1f("chromaticRatio", chromaticRatio);
1022
+ blit(null);
1023
+ }
1024
+
1025
+ let rafId: number | null = requestAnimationFrame(frame);
1026
+ function frame() {
1027
+ rafId = requestAnimationFrame(frame);
1028
+ if (pauseWhenHidden && hidden) return;
1029
+ step(FIXED_DT);
1030
+ draw();
1031
+ }
1032
+
1033
+ // Tears down the running sim's listeners/GL resources. Shared by
1034
+ // the context-loss handler below (which must stop issuing calls
1035
+ // against a dead context) and the normal unmount cleanup.
1036
+ function teardownSim() {
1037
+ if (rafId !== null) {
1038
+ cancelAnimationFrame(rafId);
1039
+ rafId = null;
1040
+ }
1041
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1042
+ window.removeEventListener("pointermove", onPointerMove);
1043
+ resizeObserver.disconnect();
1044
+ destroySimBuffers();
1045
+ if (textTexture) {
1046
+ gl.deleteTexture(textTexture);
1047
+ textTexture = null;
1048
+ }
1049
+ for (const p of [
1050
+ advectionProgram,
1051
+ splatProgram,
1052
+ diffuseProgram,
1053
+ divergenceProgram,
1054
+ pressureProgram,
1055
+ gradientSubtractProgram,
1056
+ copyProgram,
1057
+ displayProgram,
1058
+ ]) {
1059
+ p.dispose();
1060
+ }
1061
+ disposeGeometry();
1062
+ rasterize = null;
1063
+ }
1064
+
1065
+ // A real GPU context loss (driver reset, GPU switch, too many live
1066
+ // contexts, …) must not leave the rAF loop silently issuing calls
1067
+ // against a dead context, and — without preventDefault() — the
1068
+ // context would never become eligible for restoration. Recovery
1069
+ // here is deliberately conservative: every GL object created
1070
+ // above is invalidated by the loss, so rather than rebuilding the
1071
+ // whole sim in place this drops to the stable static DOM fallback
1072
+ // for the remainder of this mount.
1073
+ function onContextLost(event: Event) {
1074
+ event.preventDefault();
1075
+ teardownSim();
1076
+ mode = "static";
1077
+ startFallbackResize();
1078
+ }
1079
+ function onContextRestored() {
1080
+ // Intentionally inert — see onContextLost above. The static
1081
+ // fallback stays in place rather than attempting a risky
1082
+ // partial rebuild of programs/geometry/FBOs in place.
1083
+ }
1084
+ canvas.addEventListener("webglcontextlost", onContextLost, false);
1085
+ canvas.addEventListener("webglcontextrestored", onContextRestored, false);
1086
+
1087
+ disposeOnFailure.length = 0;
1088
+
1089
+ return () => {
1090
+ canvas.removeEventListener("webglcontextlost", onContextLost);
1091
+ canvas.removeEventListener("webglcontextrestored", onContextRestored);
1092
+ teardownSim();
1093
+ const loseContext = gl.getExtension("WEBGL_lose_context");
1094
+ loseContext?.loseContext();
1095
+ };
1096
+ } catch {
1097
+ // Best-effort cleanup of whatever GL resources were already
1098
+ // allocated before the failure (a program compile/link error,
1099
+ // exhausted geometry buffers, a texture allocation failure inside
1100
+ // the initial resizeAll(), …), so a failed init doesn't strand
1101
+ // them on the canvas's still-live context. The disposeOnFailure
1102
+ // entries are each individually guarded since how far setup got
1103
+ // before throwing determines which of them actually ran; the sim
1104
+ // buffers/texture are unconditionally declared above (outside
1105
+ // this try), so they're always safe to tear down here.
1106
+ for (const dispose of disposeOnFailure) {
1107
+ try {
1108
+ dispose();
1109
+ } catch {
1110
+ /* already gone, or the context is no longer usable */
1111
+ }
1112
+ }
1113
+ destroySimBuffers();
1114
+ if (textTexture) gl.deleteTexture(textTexture);
1115
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
1116
+ // Falls back to the static DOM text path rather than throwing.
1117
+ mode = "static";
1118
+ return null;
1119
+ }
1120
+ }
1121
+ </script>
1122
+
1123
+ <div bind:this={rootEl} class={cn("liquid-text relative block w-full", className)} style:height={rootHeight}>
1124
+ <canvas
1125
+ bind:this={canvasEl}
1126
+ aria-hidden="true"
1127
+ class={cn("pointer-events-none absolute inset-0 block h-full w-full", mode === "canvas" ? "" : "invisible")}
1128
+ ></canvas>
1129
+ {#if mode === "canvas"}
1130
+ <span class="sr-only">{text}</span>
1131
+ {:else}
1132
+ <span
1133
+ class="liquid-text-fallback block"
1134
+ style:color={themeColor}
1135
+ style:font-family={resolvedFont}
1136
+ style:font-size={`${displayFontSize}px`}
1137
+ style:font-weight={fontWeight}
1138
+ >{text}</span>
1139
+ {/if}
1140
+ </div>
1141
+
1142
+ <style>
1143
+ .liquid-text {
1144
+ line-height: 1;
1145
+ }
1146
+
1147
+ .liquid-text-fallback {
1148
+ margin: 0;
1149
+ white-space: nowrap;
1150
+ }
1151
+ </style>