{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"liquid-text","type":"registry:ui","title":"LiquidText","description":"Big text that liquefies along the cursor's path via a raw-WebGL fluid solver, with chromatic-aberration fringing that relaxes back over time","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/liquid-text/LiquidText.tsx","content":"\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { cn } from \"../../utils\";\nimport \"./liquid-text.css\";\n\nexport interface LiquidTextProps {\n\t/** Text rendered into the fluid texture (or as static fallback text). */\n\ttext?: string;\n\t/** CSS font-family. Empty string resolves to getComputedStyle(host).fontFamily. */\n\tfont?: string;\n\t/** Font size in px. 0 auto-fits the text to the container's width. */\n\tfontSize?: number;\n\t/** CSS font-weight for the rasterized/fallback text. */\n\tfontWeight?: number | string;\n\t/** Text color used in light mode. */\n\tlightColor?: string;\n\t/** Text color used in dark mode. */\n\tdarkColor?: string;\n\t/** Additional CSS classes applied to the root element. */\n\tclassName?: string;\n\t/** Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. */\n\tstrength?: number;\n\t/** Splat radius in screen pixels around the pointer. */\n\tradius?: number;\n\t/** Multiplier from mouse-delta-per-frame to splat force. */\n\tforceGain?: number;\n\t/** Per-frame velocity decay factor (relax-back rate for the smear). */\n\tdissipation?: number;\n\t/** Viscous diffusion strength (Jacobi iteration, 8 iters). */\n\tviscosity?: number;\n\t/** Chromatic offset = warp amount x this ratio. */\n\tchromaticRatio?: number;\n\t/** If window.innerWidth <= staticBelow at mount, render static DOM text instead. */\n\tstaticBelow?: number;\n\t/** Whether the fluid sim reacts to pointer movement. */\n\tinteractive?: boolean;\n\t/** Pause the render loop via visibilitychange when the tab/page is hidden. */\n\tpauseWhenHidden?: boolean;\n}\n\n// ===========================================================================\n// Pure WebGL helpers (stateless — take `gl` as a parameter). Structurally\n// modeled on the FluidCursor pass pipeline (DoubleFBO ping-pong, blit-driven\n// fullscreen passes, Jacobi iteration), rewritten from scratch for a\n// text-displacement sim: velocity-only field (no dye buffer), an added\n// viscous-diffusion pass, and a screen-px splat falloff instead of a\n// gaussian one. The full pass order is documented on the shader constants\n// and on `step()` below.\n// ===========================================================================\n\ntype GL = WebGLRenderingContext | WebGL2RenderingContext;\n\ninterface Fbo {\n\ttexture: WebGLTexture;\n\tframebuffer: WebGLFramebuffer;\n\twidth: number;\n\theight: number;\n\ttexelSizeX: number;\n\ttexelSizeY: number;\n\tattach: (unit: number) => number;\n}\n\ninterface DoubleFbo {\n\twidth: number;\n\theight: number;\n\ttexelSizeX: number;\n\ttexelSizeY: number;\n\tread: Fbo;\n\twrite: Fbo;\n\tswap: () => void;\n}\n\ninterface TexFormat {\n\tinternalFormat: number;\n\tformat: number;\n}\n\ninterface GlExt {\n\tisWebGL2: boolean;\n\thalfFloatTexType: number;\n\tsupportLinearFiltering: boolean;\n\tvelocityFormat: TexFormat;\n\tscalarFormat: TexFormat;\n}\n\nfunction compileShader(gl: GL, type: number, source: string): WebGLShader {\n\tconst shader = gl.createShader(type);\n\tif (!shader) throw new Error(\"LiquidText: unable to create shader\");\n\tgl.shaderSource(shader, source);\n\tgl.compileShader(shader);\n\tif (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n\t\tconst info = gl.getShaderInfoLog(shader) ?? \"unknown error\";\n\t\tgl.deleteShader(shader);\n\t\tthrow new Error(`LiquidText: shader compile failed: ${info}`);\n\t}\n\treturn shader;\n}\n\nfunction linkProgram(gl: GL, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {\n\tconst program = gl.createProgram();\n\tif (!program) throw new Error(\"LiquidText: unable to create program\");\n\tgl.attachShader(program, vertexShader);\n\tgl.attachShader(program, fragmentShader);\n\tgl.linkProgram(program);\n\tif (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n\t\tconst info = gl.getProgramInfoLog(program) ?? \"unknown error\";\n\t\tgl.deleteProgram(program);\n\t\tthrow new Error(`LiquidText: program link failed: ${info}`);\n\t}\n\treturn program;\n}\n\nfunction uniformLocations(gl: GL, program: WebGLProgram): Map<string, WebGLUniformLocation> {\n\tconst map = new Map<string, WebGLUniformLocation>();\n\tconst count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;\n\tfor (let i = 0; i < count; i++) {\n\t\tconst info = gl.getActiveUniform(program, i);\n\t\tif (!info) continue;\n\t\tconst loc = gl.getUniformLocation(program, info.name);\n\t\tif (loc) map.set(info.name, loc);\n\t}\n\treturn map;\n}\n\nclass GlProgram {\n\treadonly program: WebGLProgram;\n\treadonly uniforms: Map<string, WebGLUniformLocation>;\n\tprivate readonly gl: GL;\n\n\tconstructor(gl: GL, vertexShader: WebGLShader, fragmentSource: string) {\n\t\tthis.gl = gl;\n\t\tconst fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);\n\t\tthis.program = linkProgram(gl, vertexShader, fragmentShader);\n\t\tgl.deleteShader(fragmentShader);\n\t\tthis.uniforms = uniformLocations(gl, this.program);\n\t}\n\n\tuse() {\n\t\tthis.gl.useProgram(this.program);\n\t}\n\n\tset1f(name: string, value: number) {\n\t\tconst loc = this.uniforms.get(name);\n\t\tif (loc) this.gl.uniform1f(loc, value);\n\t}\n\n\tset2f(name: string, x: number, y: number) {\n\t\tconst loc = this.uniforms.get(name);\n\t\tif (loc) this.gl.uniform2f(loc, x, y);\n\t}\n\n\tset1i(name: string, value: number) {\n\t\tconst loc = this.uniforms.get(name);\n\t\tif (loc) this.gl.uniform1i(loc, value);\n\t}\n\n\tdispose() {\n\t\tthis.gl.deleteProgram(this.program);\n\t}\n}\n\nfunction createFbo(\n\tgl: GL,\n\twidth: number,\n\theight: number,\n\tfmt: TexFormat,\n\ttype: number,\n\tfilter: number\n): Fbo {\n\tgl.activeTexture(gl.TEXTURE0);\n\tconst texture = gl.createTexture();\n\tif (!texture) throw new Error(\"LiquidText: unable to allocate texture\");\n\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\tgl.texImage2D(gl.TEXTURE_2D, 0, fmt.internalFormat, width, height, 0, fmt.format, type, null);\n\n\tconst framebuffer = gl.createFramebuffer();\n\tif (!framebuffer) throw new Error(\"LiquidText: unable to allocate framebuffer\");\n\tgl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);\n\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\tgl.viewport(0, 0, width, height);\n\tgl.clearColor(0, 0, 0, 0);\n\tgl.clear(gl.COLOR_BUFFER_BIT);\n\n\treturn {\n\t\ttexture,\n\t\tframebuffer,\n\t\twidth,\n\t\theight,\n\t\ttexelSizeX: 1 / width,\n\t\ttexelSizeY: 1 / height,\n\t\tattach: (unit: number) => {\n\t\t\tgl.activeTexture(gl.TEXTURE0 + unit);\n\t\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\t\treturn unit;\n\t\t},\n\t};\n}\n\nfunction deleteFbo(gl: GL, fbo: Fbo | null | undefined) {\n\tif (!fbo) return;\n\tgl.deleteTexture(fbo.texture);\n\tgl.deleteFramebuffer(fbo.framebuffer);\n}\n\nfunction createDoubleFbo(\n\tgl: GL,\n\twidth: number,\n\theight: number,\n\tfmt: TexFormat,\n\ttype: number,\n\tfilter: number\n): DoubleFbo {\n\tlet read = createFbo(gl, width, height, fmt, type, filter);\n\tlet write = createFbo(gl, width, height, fmt, type, filter);\n\treturn {\n\t\twidth,\n\t\theight,\n\t\ttexelSizeX: read.texelSizeX,\n\t\ttexelSizeY: read.texelSizeY,\n\t\tget read() {\n\t\t\treturn read;\n\t\t},\n\t\tget write() {\n\t\t\treturn write;\n\t\t},\n\t\tswap() {\n\t\t\tconst tmp = read;\n\t\t\tread = write;\n\t\t\twrite = tmp;\n\t\t},\n\t};\n}\n\nfunction deleteDoubleFbo(gl: GL, dfbo: DoubleFbo | null | undefined) {\n\tif (!dfbo) return;\n\tdeleteFbo(gl, dfbo.read);\n\tdeleteFbo(gl, dfbo.write);\n}\n\nfunction textureRenderable(gl: GL, internalFormat: number, format: number, type: number): boolean {\n\tconst texture = gl.createTexture();\n\tif (!texture) return false;\n\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\tgl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);\n\n\tconst framebuffer = gl.createFramebuffer();\n\tlet ok = false;\n\tif (framebuffer) {\n\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);\n\t\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\t\tok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;\n\t\tgl.deleteFramebuffer(framebuffer);\n\t}\n\tgl.deleteTexture(texture);\n\treturn ok;\n}\n\nfunction resolveFormat(\n\tgl: GL,\n\tisWebGL2: boolean,\n\tkind: \"velocity\" | \"scalar\",\n\ttype: number\n): TexFormat | null {\n\tif (!isWebGL2) {\n\t\treturn textureRenderable(gl, gl.RGBA, gl.RGBA, type)\n\t\t\t? { internalFormat: gl.RGBA, format: gl.RGBA }\n\t\t\t: null;\n\t}\n\tconst gl2 = gl as WebGL2RenderingContext;\n\tconst candidates: TexFormat[] =\n\t\tkind === \"velocity\"\n\t\t\t? [\n\t\t\t\t\t{ internalFormat: gl2.RG16F, format: gl2.RG },\n\t\t\t\t\t{ internalFormat: gl2.RGBA16F, format: gl2.RGBA },\n\t\t\t\t]\n\t\t\t: [\n\t\t\t\t\t{ internalFormat: gl2.R16F, format: gl2.RED },\n\t\t\t\t\t{ internalFormat: gl2.RG16F, format: gl2.RG },\n\t\t\t\t\t{ internalFormat: gl2.RGBA16F, format: gl2.RGBA },\n\t\t\t\t];\n\tfor (const candidate of candidates) {\n\t\tif (textureRenderable(gl, candidate.internalFormat, candidate.format, type)) return candidate;\n\t}\n\treturn null;\n}\n\nfunction acquireGL(canvas: HTMLCanvasElement): { gl: GL; ext: GlExt } | null {\n\tconst attrs: WebGLContextAttributes = {\n\t\talpha: true,\n\t\tdepth: false,\n\t\tstencil: false,\n\t\tantialias: false,\n\t\tpreserveDrawingBuffer: false,\n\t};\n\n\tlet gl: GL | null = canvas.getContext(\"webgl2\", attrs) as WebGL2RenderingContext | null;\n\tlet isWebGL2 = !!gl;\n\tif (!gl) {\n\t\tgl = (canvas.getContext(\"webgl\", attrs) ??\n\t\t\tcanvas.getContext(\"experimental-webgl\", attrs)) as WebGLRenderingContext | null;\n\t\tisWebGL2 = false;\n\t}\n\tif (!gl) return null;\n\n\tlet halfFloatTexType: number;\n\tlet supportLinearFiltering: boolean;\n\n\tif (isWebGL2) {\n\t\tconst gl2 = gl as WebGL2RenderingContext;\n\t\tgl2.getExtension(\"EXT_color_buffer_float\");\n\t\t// This component only ever allocates HALF_FLOAT (RG16F/R16F/RGBA16F)\n\t\t// textures, and 16-bit float textures are always texture-filterable\n\t\t// in core WebGL2 — OES_texture_float_linear only gates 32-bit FLOAT\n\t\t// textures, which never get allocated here. Gating on that\n\t\t// extension made the velocity FBO fall back to NEAREST (and the\n\t\t// 0.25x sim field get upsampled blocky by the display shader) on\n\t\t// the many GPUs/ANGLE backends that expose half-float without\n\t\t// float-linear.\n\t\tsupportLinearFiltering = true;\n\t\thalfFloatTexType = gl2.HALF_FLOAT;\n\t} else {\n\t\t// Some WebGL1 drivers require this to make a half-float texture\n\t\t// usable as an FBO color attachment; without it, textureRenderable\n\t\t// below fails every candidate and the component drops to the\n\t\t// static fallback even though the sim could have run.\n\t\tgl.getExtension(\"EXT_color_buffer_half_float\");\n\t\tconst halfFloat = gl.getExtension(\"OES_texture_half_float\");\n\t\tsupportLinearFiltering = !!gl.getExtension(\"OES_texture_half_float_linear\");\n\t\thalfFloatTexType = halfFloat ? halfFloat.HALF_FLOAT_OES : 0;\n\t}\n\n\tconst velocityFormat = resolveFormat(gl, isWebGL2, \"velocity\", halfFloatTexType);\n\tconst scalarFormat = resolveFormat(gl, isWebGL2, \"scalar\", halfFloatTexType);\n\tif (!velocityFormat || !scalarFormat) return null;\n\n\treturn {\n\t\tgl,\n\t\text: { isWebGL2, halfFloatTexType, supportLinearFiltering, velocityFormat, scalarFormat },\n\t};\n}\n\nfunction createBlit(gl: GL) {\n\tconst vertexBuffer = gl.createBuffer();\n\tconst indexBuffer = gl.createBuffer();\n\tif (!vertexBuffer || !indexBuffer)\n\t\tthrow new Error(\"LiquidText: unable to allocate geometry buffers\");\n\n\tgl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);\n\tgl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);\n\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);\n\tgl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);\n\tgl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\tgl.enableVertexAttribArray(0);\n\n\tfunction blit(target: Fbo | null) {\n\t\tif (target) {\n\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);\n\t\t\tgl.viewport(0, 0, target.width, target.height);\n\t\t} else {\n\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\t\t\tgl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n\t\t}\n\t\tgl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);\n\t}\n\n\tfunction dispose() {\n\t\tgl.deleteBuffer(vertexBuffer);\n\t\tgl.deleteBuffer(indexBuffer);\n\t}\n\n\treturn { blit, dispose };\n}\n\n// ===========================================================================\n// Shader sources\n// ===========================================================================\n\nconst VERTEX_SHADER = `\n\tprecision highp float;\n\tattribute vec2 aPos;\n\tvarying vec2 vUv;\n\tvarying vec2 vL;\n\tvarying vec2 vR;\n\tvarying vec2 vT;\n\tvarying vec2 vB;\n\tuniform vec2 texelSize;\n\n\tvoid main () {\n\t\tvUv = aPos * 0.5 + 0.5;\n\t\tvL = vUv - vec2(texelSize.x, 0.0);\n\t\tvR = vUv + vec2(texelSize.x, 0.0);\n\t\tvT = vUv + vec2(0.0, texelSize.y);\n\t\tvB = vUv - vec2(0.0, texelSize.y);\n\t\tgl_Position = vec4(aPos, 0.0, 1.0);\n\t}\n`;\n\nconst COPY_SHADER = `\n\tprecision mediump float;\n\tprecision mediump sampler2D;\n\tvarying vec2 vUv;\n\tuniform sampler2D uTexture;\n\tvoid main () {\n\t\tgl_FragColor = texture2D(uTexture, vUv);\n\t}\n`;\n\n// Self-advects the velocity field along its own flow, then applies the\n// per-frame dissipation decay (a plain multiplicative decay — not a\n// dt-scaled rate — so `dissipation` reads directly as \"fraction of\n// velocity kept per frame\").\nconst ADVECTION_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tuniform sampler2D uVelocity;\n\tuniform float dt;\n\tuniform float dissipation;\n\n\tvoid main () {\n\t\tvec2 back = vUv - dt * texture2D(uVelocity, vUv).xy;\n\t\tvec2 advected = texture2D(uVelocity, back).xy;\n\t\tgl_FragColor = vec4(clamp(advected * dissipation, -4.0, 4.0), 0.0, 1.0);\n\t}\n`;\n\n// Injects pointer force at `point` (UV) with a screen-px radius falloff:\n// (1 - clamp(dist/radius, 0, 1))^2 * 1.5, dist measured in real screen px\n// via `resolution` (the container's CSS size), independent of the sim's\n// own (lower) texel resolution.\nconst SPLAT_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tuniform sampler2D uVelocity;\n\tuniform vec2 point;\n\tuniform vec2 resolution;\n\tuniform float radius;\n\tuniform vec2 force;\n\n\tvoid main () {\n\t\tvec2 diffPx = (vUv - point) * resolution;\n\t\tfloat dist = length(diffPx);\n\t\tfloat t = clamp(1.0 - dist / max(radius, 1.0), 0.0, 1.0);\n\t\tfloat falloff = t * t * 1.5;\n\t\tvec2 base = texture2D(uVelocity, vUv).xy;\n\t\tgl_FragColor = vec4(clamp(base + force * falloff, -4.0, 4.0), 0.0, 1.0);\n\t}\n`;\n\n// One Jacobi iteration of implicit viscous diffusion:\n// x_new = (x0 + alpha*(xL+xR+xT+xB)) / (1 + 4*alpha), alpha = viscosity*dt.\n// `uSource` is the fixed pre-diffusion snapshot (x0); `uVelocity` is the\n// current iterate, ping-ponged across 8 calls.\nconst DIFFUSE_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tvarying vec2 vL;\n\tvarying vec2 vR;\n\tvarying vec2 vT;\n\tvarying vec2 vB;\n\tuniform sampler2D uVelocity;\n\tuniform sampler2D uSource;\n\tuniform float alpha;\n\n\tvoid main () {\n\t\tvec2 xL = texture2D(uVelocity, vL).xy;\n\t\tvec2 xR = texture2D(uVelocity, vR).xy;\n\t\tvec2 xT = texture2D(uVelocity, vT).xy;\n\t\tvec2 xB = texture2D(uVelocity, vB).xy;\n\t\tvec2 x0 = texture2D(uSource, vUv).xy;\n\t\tfloat beta = 1.0 + 4.0 * alpha;\n\t\tvec2 result = (x0 + alpha * (xL + xR + xT + xB)) / beta;\n\t\tgl_FragColor = vec4(clamp(result, -4.0, 4.0), 0.0, 1.0);\n\t}\n`;\n\nconst DIVERGENCE_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tvarying vec2 vL;\n\tvarying vec2 vR;\n\tvarying vec2 vT;\n\tvarying vec2 vB;\n\tuniform sampler2D uVelocity;\n\n\tvoid main () {\n\t\tfloat L = texture2D(uVelocity, vL).x;\n\t\tfloat R = texture2D(uVelocity, vR).x;\n\t\tfloat T = texture2D(uVelocity, vT).y;\n\t\tfloat B = texture2D(uVelocity, vB).y;\n\t\tvec2 C = texture2D(uVelocity, vUv).xy;\n\t\tif (vL.x < 0.0) L = -C.x;\n\t\tif (vR.x > 1.0) R = -C.x;\n\t\tif (vT.y > 1.0) T = -C.y;\n\t\tif (vB.y < 0.0) B = -C.y;\n\t\tfloat div = 0.5 * (R - L + T - B);\n\t\tgl_FragColor = vec4(div, 0.0, 0.0, 1.0);\n\t}\n`;\n\nconst PRESSURE_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tvarying vec2 vL;\n\tvarying vec2 vR;\n\tvarying vec2 vT;\n\tvarying vec2 vB;\n\tuniform sampler2D uPressure;\n\tuniform sampler2D uDivergence;\n\n\tvoid main () {\n\t\tfloat L = texture2D(uPressure, vL).x;\n\t\tfloat R = texture2D(uPressure, vR).x;\n\t\tfloat T = texture2D(uPressure, vT).x;\n\t\tfloat B = texture2D(uPressure, vB).x;\n\t\tfloat div = texture2D(uDivergence, vUv).x;\n\t\tfloat p = (L + R + T + B - div) * 0.25;\n\t\tgl_FragColor = vec4(p, 0.0, 0.0, 1.0);\n\t}\n`;\n\nconst GRADIENT_SUBTRACT_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tvarying vec2 vL;\n\tvarying vec2 vR;\n\tvarying vec2 vT;\n\tvarying vec2 vB;\n\tuniform sampler2D uPressure;\n\tuniform sampler2D uVelocity;\n\n\tvoid main () {\n\t\tfloat L = texture2D(uPressure, vL).x;\n\t\tfloat R = texture2D(uPressure, vR).x;\n\t\tfloat T = texture2D(uPressure, vT).x;\n\t\tfloat B = texture2D(uPressure, vB).x;\n\t\tvec2 vel = texture2D(uVelocity, vUv).xy;\n\t\tvel -= 0.5 * vec2(R - L, T - B);\n\t\tgl_FragColor = vec4(clamp(vel, -4.0, 4.0), 0.0, 1.0);\n\t}\n`;\n\n// Final composite: warps the rasterized-text UV by the velocity field and\n// takes 3 independently-offset taps (R/G/B) for the chromatic-aberration\n// fringe. Alpha is the max of the 3 taps' alpha so the fringe itself is\n// visible over the transparent background rather than being clipped to the\n// unwarped glyph silhouette.\nconst DISPLAY_SHADER = `\n\tprecision highp float;\n\tprecision highp sampler2D;\n\tvarying vec2 vUv;\n\tuniform sampler2D uText;\n\tuniform sampler2D uVelocity;\n\tuniform float strength;\n\tuniform float chromaticRatio;\n\n\tvoid main () {\n\t\tvec2 vel = texture2D(uVelocity, vUv).xy;\n\t\tvec2 warp = vel * strength;\n\t\tvec2 chroma = warp * chromaticRatio;\n\t\tvec2 base = vUv - warp;\n\t\tvec4 rTap = texture2D(uText, base - chroma);\n\t\tvec4 gTap = texture2D(uText, base);\n\t\tvec4 bTap = texture2D(uText, base + chroma);\n\t\tvec3 color = vec3(rTap.r, gTap.g, bTap.b);\n\t\tfloat a = max(rTap.a, max(gTap.a, bTap.a));\n\t\tgl_FragColor = vec4(color, a);\n\t}\n`;\n\nconst FIXED_DT = 0.016;\nconst DIFFUSE_ITERATIONS = 8;\nconst PRESSURE_ITERATIONS = 16;\nconst SIM_SCALE = 0.25;\n\n// ===========================================================================\n// Component\n// ===========================================================================\n\nexport function LiquidText({\n\ttext = \"Liquid\",\n\tfont = \"\",\n\tfontSize = 0,\n\tfontWeight = 700,\n\tlightColor = \"#000000\",\n\tdarkColor = \"#ffffff\",\n\tclassName = \"\",\n\tstrength = 0.5,\n\tradius = 160,\n\tforceGain = 17,\n\tdissipation = 0.98,\n\tviscosity = 4,\n\tchromaticRatio = 0.2,\n\tstaticBelow = 1024,\n\tinteractive = true,\n\tpauseWhenHidden = true,\n}: LiquidTextProps) {\n\tconst rootRef = useRef<HTMLDivElement | null>(null);\n\tconst canvasRef = useRef<HTMLCanvasElement | null>(null);\n\n\t// Always starts \"static\" so the very first render (SSR or hydration) never\n\t// touches window/document — the mount effect promotes to \"canvas\" if\n\t// eligible.\n\tconst [mode, setMode] = useState<\"static\" | \"canvas\">(\"static\");\n\tconst [themeColor, setThemeColor] = useState(lightColor);\n\tconst [resolvedFont, setResolvedFont] = useState(font);\n\tconst [displayFontSize, setDisplayFontSize] = useState(fontSize > 0 ? fontSize : 64);\n\n\t// Synchronous mirrors of the three values above. A Svelte `$state`\n\t// assignment is readable on the very next line; a React setter is not, and\n\t// `resizeAll` below reads the fitted font size immediately after\n\t// `fitFontSize()` writes it. The refs are what the sim reads, the state is\n\t// what the fallback markup renders — they are always written together.\n\tconst themeColorRef = useRef(themeColor);\n\tconst resolvedFontRef = useRef(resolvedFont);\n\tconst displayFontSizeRef = useRef(displayFontSize);\n\n\t// Hoisted so the prop-reactivity effect below can push a re-rasterize into\n\t// the live WebGL texture after mount. Assigned by setupCanvasSim once the\n\t// sim is running; stays null on the static-fallback path, where updating\n\t// the state above is enough since the fallback is just reactive markup.\n\tconst rasterizeRef = useRef<(() => void) | null>(null);\n\n\t// Every prop the running sim reads live. The Svelte source reads the\n\t// destructured `$props()` bindings straight from its long-lived closures\n\t// (the pointer handler, the rAF loop, the observers) and therefore always\n\t// sees the current value; a React `[]`-deps effect would otherwise freeze\n\t// the mount-time values into those same closures.\n\tconst live = useLiveRef({\n\t\ttext,\n\t\tfont,\n\t\tfontSize,\n\t\tfontWeight,\n\t\tlightColor,\n\t\tdarkColor,\n\t\tstrength,\n\t\tradius,\n\t\tforceGain,\n\t\tdissipation,\n\t\tviscosity,\n\t\tchromaticRatio,\n\t\tinteractive,\n\t\tpauseWhenHidden,\n\t});\n\n\tconst rootHeight = `${Math.round(displayFontSize * 1.2)}px`;\n\n\tconst activeThemeColor = useCallback((): string => {\n\t\treturn typeof document !== \"undefined\" && document.documentElement.classList.contains(\"dark\")\n\t\t\t? live.current.darkColor\n\t\t\t: live.current.lightColor;\n\t}, [live]);\n\n\tconst applyThemeColor = useCallback((next: string) => {\n\t\tthemeColorRef.current = next;\n\t\tsetThemeColor(next);\n\t}, []);\n\n\t// Recomputes resolvedFont + displayFontSize together (using a local\n\t// `family` const rather than re-reading the resolved font it just wrote) so\n\t// this can safely run inside the reactive effect below without the effect\n\t// both writing and re-reading the same piece of state in one pass.\n\tconst fitFontSize = useCallback(() => {\n\t\tconst rootEl = rootRef.current;\n\t\tif (!rootEl) return;\n\t\tconst { font, fontSize, fontWeight, text } = live.current;\n\t\tconst family = font || getComputedStyle(rootEl).fontFamily || \"sans-serif\";\n\t\tresolvedFontRef.current = family;\n\t\tsetResolvedFont(family);\n\t\tif (fontSize > 0) {\n\t\t\tdisplayFontSizeRef.current = fontSize;\n\t\t\tsetDisplayFontSize(fontSize);\n\t\t\treturn;\n\t\t}\n\t\tconst containerWidth = rootEl.clientWidth || 1;\n\t\tconst probe = document.createElement(\"canvas\").getContext(\"2d\");\n\t\tif (!probe) return;\n\t\tconst reference = 100;\n\t\tprobe.font = `${fontWeight} ${reference}px ${family}`;\n\t\tconst measured = probe.measureText(text).width || 1;\n\t\tconst fitted = reference * (containerWidth / measured);\n\t\tconst next = Math.min(Math.max(fitted, 8), 2000);\n\t\tdisplayFontSizeRef.current = next;\n\t\tsetDisplayFontSize(next);\n\t}, [live]);\n\n\t// Per the \"text/font changes re-rasterize\" requirement: the WebGL path\n\t// otherwise only reads text/font/fontSize/fontWeight/lightColor/darkColor\n\t// once, at setup, so updating them in e.g. a docs control would silently do\n\t// nothing. Listing the props as this effect's dependencies (rather than\n\t// only reading them inside the helpers above) means any of them changing\n\t// after mount re-derives the resolved font/size/color and, on the canvas\n\t// path, re-rasterizes.\n\t//\n\t// A layout effect, not a passive one: the fitted font size drives the root\n\t// height and the fallback text's size, so running it after paint would show\n\t// one frame at the previous (or default 64px) size on every text/font\n\t// change. The Svelte `$effect` this mirrors flushes before the browser\n\t// paints.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (!rootRef.current) return;\n\t\tapplyThemeColor(activeThemeColor());\n\t\tfitFontSize();\n\t\trasterizeRef.current?.();\n\t}, [\n\t\ttext,\n\t\tfont,\n\t\tfontSize,\n\t\tfontWeight,\n\t\tlightColor,\n\t\tdarkColor,\n\t\tactiveThemeColor,\n\t\tapplyThemeColor,\n\t\tfitFontSize,\n\t]);\n\n\t// Mount-only. `staticBelow` is deliberately read from the mount-time\n\t// closure and not listed as a dependency: the Svelte source checks it once\n\t// against `window.innerWidth` in `onMount`, so crossing the breakpoint\n\t// later does not retroactively swap modes.\n\t//\n\t// A layout effect, not a passive one: both of the paint-visible decisions\n\t// this makes — the auto-fitted font size (which sets the root height) and\n\t// the static -> canvas promotion — would otherwise land one painted frame\n\t// late, so the default `fontSize={0}` path would flash 64px text before\n\t// jumping to its fitted size. The Svelte `onMount` this mirrors flushes\n\t// before the first paint.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst root = rootRef.current;\n\t\tconst canvas = canvasRef.current;\n\t\tif (!root || !canvas) return;\n\n\t\tapplyThemeColor(activeThemeColor());\n\n\t\tconst themeObserver = new MutationObserver(() => {\n\t\t\tconst next = activeThemeColor();\n\t\t\tif (next !== themeColorRef.current) {\n\t\t\t\tapplyThemeColor(next);\n\t\t\t\trasterizeRef.current?.();\n\t\t\t}\n\t\t});\n\t\tthemeObserver.observe(document.documentElement, {\n\t\t\tattributes: true,\n\t\t\tattributeFilter: [\"class\"],\n\t\t});\n\n\t\tfitFontSize();\n\n\t\tconst reduceMotionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\t\tconst tooSmall = window.innerWidth <= staticBelow;\n\n\t\tlet cleanupCanvasSim: (() => void) | null = null;\n\t\tlet fallbackResizeObserver: ResizeObserver | null = null;\n\n\t\t// setupCanvasSim installs its own ResizeObserver; the static fallback\n\t\t// (reduced-motion, staticBelow, no WebGL, a failed GL init, or a\n\t\t// mid-session GPU context loss — see onContextLost inside\n\t\t// setupCanvasSim) has no such observer of its own, so without this the\n\t\t// auto-fit (fontSize=0) fallback text would stay frozen at its\n\t\t// mount-time size across any later container/viewport resize (e.g.\n\t\t// mobile rotation). Guarded against re-entry so it's safe to call\n\t\t// again (e.g. if a restored context is lost a second time) without\n\t\t// leaking a duplicate observer.\n\t\tfunction startFallbackResize() {\n\t\t\tif (!root || fallbackResizeObserver) return;\n\t\t\tfallbackResizeObserver = new ResizeObserver(() => fitFontSize());\n\t\t\tfallbackResizeObserver.observe(root);\n\t\t}\n\n\t\t/**\n\t\t * Attempts to acquire a WebGL context and, if successful, boots the\n\t\t * fluid sim + render loop and switches `mode` to \"canvas\". Returns a\n\t\t * cleanup function on success, or `null` if WebGL is unavailable\n\t\t * (caller should keep the static DOM fallback).\n\t\t *\n\t\t * `startFallbackResize` is the fallback-resize starter above, threaded\n\t\t * through so the context-loss handler below can re-arm it when it drops\n\t\t * the sim to the static fallback mid-session — it closes over this\n\t\t * effect's `root`/`fallbackResizeObserver`, so calling it here keeps\n\t\t * that observer reachable from the effect's own cleanup return, and\n\t\t * it's a no-op if the observer is already attached.\n\t\t */\n\t\tfunction setupCanvasSim(\n\t\t\tcanvas: HTMLCanvasElement,\n\t\t\troot: HTMLDivElement,\n\t\t\tstartFallbackResize: () => void\n\t\t): (() => void) | null {\n\t\t\tconst acquired = acquireGL(canvas);\n\t\t\tif (!acquired) return null;\n\n\t\t\tconst { gl, ext } = acquired;\n\n\t\t\t// Declared here (outside the try below) so both the success path\n\t\t\t// and the failure-recovery catch block can safely reference them —\n\t\t\t// `let`/`function` declared inside a `try { … }` are not visible\n\t\t\t// from its `catch { … }`, since try/catch are separate block\n\t\t\t// scopes.\n\t\t\tlet velocity: DoubleFbo | null = null;\n\t\t\tlet divergenceFbo: Fbo | null = null;\n\t\t\tlet pressure: DoubleFbo | null = null;\n\t\t\tlet diffuseSource: Fbo | null = null;\n\t\t\tlet textTexture: WebGLTexture | null = null;\n\t\t\tlet textCanvas: HTMLCanvasElement | null = null;\n\n\t\t\tfunction destroySimBuffers() {\n\t\t\t\tdeleteDoubleFbo(gl, velocity);\n\t\t\t\tdeleteFbo(gl, divergenceFbo);\n\t\t\t\tdeleteDoubleFbo(gl, pressure);\n\t\t\t\tdeleteFbo(gl, diffuseSource);\n\t\t\t\tvelocity = null;\n\t\t\t\tdivergenceFbo = null;\n\t\t\t\tpressure = null;\n\t\t\t\tdiffuseSource = null;\n\t\t\t}\n\n\t\t\t// Tracks GL resources as they're successfully allocated during this\n\t\t\t// synchronous setup pass, so a later failure (a program that fails\n\t\t\t// to compile/link, exhausted buffers, …) can free everything\n\t\t\t// created before it instead of stranding those objects on the\n\t\t\t// canvas's still-live GL context. Cleared once setup fully\n\t\t\t// succeeds.\n\t\t\tconst disposeOnFailure: Array<() => void> = [];\n\n\t\t\ttry {\n\t\t\t\tconst { blit, dispose: disposeGeometry } = createBlit(gl);\n\t\t\t\tdisposeOnFailure.push(disposeGeometry);\n\n\t\t\t\tconst vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);\n\t\t\t\tdisposeOnFailure.push(() => gl.deleteShader(vertexShader));\n\n\t\t\t\tfunction makeProgram(fragmentSource: string): GlProgram {\n\t\t\t\t\tconst program = new GlProgram(gl, vertexShader, fragmentSource);\n\t\t\t\t\tdisposeOnFailure.push(() => program.dispose());\n\t\t\t\t\treturn program;\n\t\t\t\t}\n\n\t\t\t\tconst advectionProgram = makeProgram(ADVECTION_SHADER);\n\t\t\t\tconst splatProgram = makeProgram(SPLAT_SHADER);\n\t\t\t\tconst diffuseProgram = makeProgram(DIFFUSE_SHADER);\n\t\t\t\tconst divergenceProgram = makeProgram(DIVERGENCE_SHADER);\n\t\t\t\tconst pressureProgram = makeProgram(PRESSURE_SHADER);\n\t\t\t\tconst gradientSubtractProgram = makeProgram(GRADIENT_SUBTRACT_SHADER);\n\t\t\t\tconst copyProgram = makeProgram(COPY_SHADER);\n\t\t\t\tconst displayProgram = makeProgram(DISPLAY_SHADER);\n\t\t\t\tgl.deleteShader(vertexShader);\n\n\t\t\t\tconst filter = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;\n\n\t\t\t\tlet cssWidth = 0;\n\t\t\t\tlet cssHeight = 0;\n\n\t\t\t\tfunction createSimBuffers(simW: number, simH: number) {\n\t\t\t\t\tvelocity = createDoubleFbo(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\tsimW,\n\t\t\t\t\t\tsimH,\n\t\t\t\t\t\text.velocityFormat,\n\t\t\t\t\t\text.halfFloatTexType,\n\t\t\t\t\t\tfilter\n\t\t\t\t\t);\n\t\t\t\t\tdivergenceFbo = createFbo(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\tsimW,\n\t\t\t\t\t\tsimH,\n\t\t\t\t\t\text.scalarFormat,\n\t\t\t\t\t\text.halfFloatTexType,\n\t\t\t\t\t\tgl.NEAREST\n\t\t\t\t\t);\n\t\t\t\t\tpressure = createDoubleFbo(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\tsimW,\n\t\t\t\t\t\tsimH,\n\t\t\t\t\t\text.scalarFormat,\n\t\t\t\t\t\text.halfFloatTexType,\n\t\t\t\t\t\tgl.NEAREST\n\t\t\t\t\t);\n\t\t\t\t\tdiffuseSource = createFbo(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\tsimW,\n\t\t\t\t\t\tsimH,\n\t\t\t\t\t\text.velocityFormat,\n\t\t\t\t\t\text.halfFloatTexType,\n\t\t\t\t\t\tfilter\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tfunction rasterizeText() {\n\t\t\t\t\tconst dpr = window.devicePixelRatio || 1;\n\t\t\t\t\tconst w = Math.max(1, Math.round(cssWidth * dpr));\n\t\t\t\t\tconst h = Math.max(1, Math.round(cssHeight * dpr));\n\t\t\t\t\tif (!textCanvas) textCanvas = document.createElement(\"canvas\");\n\t\t\t\t\ttextCanvas.width = w;\n\t\t\t\t\ttextCanvas.height = h;\n\t\t\t\t\tconst ctx = textCanvas.getContext(\"2d\");\n\t\t\t\t\tif (!ctx) return;\n\t\t\t\t\tctx.clearRect(0, 0, w, h);\n\t\t\t\t\tctx.font = `${live.current.fontWeight} ${displayFontSizeRef.current * dpr}px ${resolvedFontRef.current}`;\n\t\t\t\t\tctx.fillStyle = themeColorRef.current;\n\t\t\t\t\tctx.textAlign = \"left\";\n\t\t\t\t\tctx.textBaseline = \"middle\";\n\t\t\t\t\tctx.fillText(live.current.text, 0, h / 2);\n\n\t\t\t\t\tif (!textTexture) textTexture = gl.createTexture();\n\t\t\t\t\tgl.bindTexture(gl.TEXTURE_2D, textTexture);\n\t\t\t\t\tgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n\t\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n\t\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n\t\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\t\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\t\t\t\t\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);\n\t\t\t\t\tgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);\n\t\t\t\t}\n\n\t\t\t\tfunction resizeAll() {\n\t\t\t\t\tfitFontSize();\n\t\t\t\t\tconst dpr = window.devicePixelRatio || 1;\n\t\t\t\t\tcssWidth = root.clientWidth || 1;\n\t\t\t\t\tcssHeight = Math.round(displayFontSizeRef.current * 1.2) || 1;\n\t\t\t\t\tconst pxW = Math.max(1, Math.round(cssWidth * dpr));\n\t\t\t\t\tconst pxH = Math.max(1, Math.round(cssHeight * dpr));\n\t\t\t\t\tif (canvas.width !== pxW || canvas.height !== pxH) {\n\t\t\t\t\t\tcanvas.width = pxW;\n\t\t\t\t\t\tcanvas.height = pxH;\n\t\t\t\t\t}\n\t\t\t\t\tconst simW = Math.max(1, Math.round(pxW * SIM_SCALE));\n\t\t\t\t\tconst simH = Math.max(1, Math.round(pxH * SIM_SCALE));\n\t\t\t\t\tif (!velocity || velocity.width !== simW || velocity.height !== simH) {\n\t\t\t\t\t\tdestroySimBuffers();\n\t\t\t\t\t\tcreateSimBuffers(simW, simH);\n\t\t\t\t\t}\n\t\t\t\t\trasterizeText();\n\t\t\t\t}\n\n\t\t\t\tresizeAll();\n\t\t\t\tsetMode(\"canvas\");\n\t\t\t\trasterizeRef.current = rasterizeText;\n\n\t\t\t\tconst resizeObserver = new ResizeObserver(() => resizeAll());\n\t\t\t\tresizeObserver.observe(root);\n\n\t\t\t\t// --- pointer tracking (UV space, y=1 at top to match vUv) ---\n\t\t\t\tconst pointerUV = { x: 0.5, y: 0.5 };\n\t\t\t\tconst pointerPrevUV = { x: 0.5, y: 0.5 };\n\t\t\t\tconst pointerForce = { x: 0, y: 0 };\n\t\t\t\tlet pointerHasPrev = false;\n\t\t\t\tlet pointerMoved = false;\n\n\t\t\t\tfunction onPointerMove(e: PointerEvent) {\n\t\t\t\t\tif (!live.current.interactive) return;\n\t\t\t\t\tconst rect = root.getBoundingClientRect();\n\t\t\t\t\tif (rect.width <= 0 || rect.height <= 0) return;\n\t\t\t\t\tconst x = (e.clientX - rect.left) / rect.width;\n\t\t\t\t\tconst y = 1 - (e.clientY - rect.top) / rect.height;\n\t\t\t\t\tif (pointerHasPrev) {\n\t\t\t\t\t\tpointerForce.x = (x - pointerPrevUV.x) * live.current.forceGain;\n\t\t\t\t\t\tpointerForce.y = (y - pointerPrevUV.y) * live.current.forceGain;\n\t\t\t\t\t\tpointerMoved = true;\n\t\t\t\t\t}\n\t\t\t\t\tpointerPrevUV.x = x;\n\t\t\t\t\tpointerPrevUV.y = y;\n\t\t\t\t\tpointerUV.x = x;\n\t\t\t\t\tpointerUV.y = y;\n\t\t\t\t\tpointerHasPrev = true;\n\t\t\t\t}\n\n\t\t\t\t// Always registered — `interactive` is read live inside\n\t\t\t\t// onPointerMove above, so toggling the prop after mount (e.g. a\n\t\t\t\t// docs control) takes effect immediately instead of only\n\t\t\t\t// reflecting its value at initial setup.\n\t\t\t\twindow.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n\n\t\t\t\t// --- visibility pause ---\n\t\t\t\tlet hidden = document.hidden;\n\t\t\t\tfunction onVisibilityChange() {\n\t\t\t\t\thidden = document.hidden;\n\t\t\t\t}\n\t\t\t\t// Always registered — `pauseWhenHidden` is read live in frame()\n\t\t\t\t// below, so toggling the prop after mount takes effect\n\t\t\t\t// immediately instead of only reflecting its value at initial\n\t\t\t\t// setup.\n\t\t\t\tdocument.addEventListener(\"visibilitychange\", onVisibilityChange);\n\n\t\t\t\t// --- sim passes ---\n\t\t\t\tfunction applySplat() {\n\t\t\t\t\tif (!pointerMoved || !velocity) return;\n\t\t\t\t\tpointerMoved = false;\n\t\t\t\t\tsplatProgram.use();\n\t\t\t\t\tsplatProgram.set2f(\"point\", pointerUV.x, pointerUV.y);\n\t\t\t\t\tsplatProgram.set2f(\"resolution\", cssWidth, cssHeight);\n\t\t\t\t\tsplatProgram.set1f(\"radius\", live.current.radius);\n\t\t\t\t\tsplatProgram.set2f(\"force\", pointerForce.x, pointerForce.y);\n\t\t\t\t\tsplatProgram.set1i(\"uVelocity\", velocity.read.attach(0));\n\t\t\t\t\tblit(velocity.write);\n\t\t\t\t\tvelocity.swap();\n\t\t\t\t}\n\n\t\t\t\tfunction diffuseVelocity(dt: number) {\n\t\t\t\t\tif (!velocity || !diffuseSource) return;\n\t\t\t\t\tcopyProgram.use();\n\t\t\t\t\tcopyProgram.set1i(\"uTexture\", velocity.read.attach(0));\n\t\t\t\t\tblit(diffuseSource);\n\n\t\t\t\t\tconst alpha = live.current.viscosity * dt;\n\t\t\t\t\tfor (let i = 0; i < DIFFUSE_ITERATIONS; i++) {\n\t\t\t\t\t\tdiffuseProgram.use();\n\t\t\t\t\t\tdiffuseProgram.set2f(\"texelSize\", velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t\t\tdiffuseProgram.set1i(\"uVelocity\", velocity.read.attach(0));\n\t\t\t\t\t\tdiffuseProgram.set1i(\"uSource\", diffuseSource.attach(1));\n\t\t\t\t\t\tdiffuseProgram.set1f(\"alpha\", alpha);\n\t\t\t\t\t\tblit(velocity.write);\n\t\t\t\t\t\tvelocity.swap();\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfunction clearFbo(fbo: Fbo) {\n\t\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, fbo.framebuffer);\n\t\t\t\t\tgl.viewport(0, 0, fbo.width, fbo.height);\n\t\t\t\t\tgl.clearColor(0, 0, 0, 0);\n\t\t\t\t\tgl.clear(gl.COLOR_BUFFER_BIT);\n\t\t\t\t}\n\n\t\t\t\tfunction step(dt: number) {\n\t\t\t\t\tif (!velocity || !divergenceFbo || !pressure) return;\n\t\t\t\t\tgl.disable(gl.BLEND);\n\n\t\t\t\t\tadvectionProgram.use();\n\t\t\t\t\tadvectionProgram.set2f(\"texelSize\", velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t\tadvectionProgram.set1i(\"uVelocity\", velocity.read.attach(0));\n\t\t\t\t\tadvectionProgram.set1f(\"dt\", dt);\n\t\t\t\t\tadvectionProgram.set1f(\"dissipation\", live.current.dissipation);\n\t\t\t\t\tblit(velocity.write);\n\t\t\t\t\tvelocity.swap();\n\n\t\t\t\t\tapplySplat();\n\t\t\t\t\tdiffuseVelocity(dt);\n\n\t\t\t\t\tdivergenceProgram.use();\n\t\t\t\t\tdivergenceProgram.set2f(\"texelSize\", velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t\tdivergenceProgram.set1i(\"uVelocity\", velocity.read.attach(0));\n\t\t\t\t\tblit(divergenceFbo);\n\n\t\t\t\t\tclearFbo(pressure.write);\n\t\t\t\t\tpressure.swap();\n\t\t\t\t\tfor (let i = 0; i < PRESSURE_ITERATIONS; i++) {\n\t\t\t\t\t\tpressureProgram.use();\n\t\t\t\t\t\tpressureProgram.set2f(\"texelSize\", velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t\t\tpressureProgram.set1i(\"uPressure\", pressure.read.attach(0));\n\t\t\t\t\t\tpressureProgram.set1i(\"uDivergence\", divergenceFbo.attach(1));\n\t\t\t\t\t\tblit(pressure.write);\n\t\t\t\t\t\tpressure.swap();\n\t\t\t\t\t}\n\n\t\t\t\t\tgradientSubtractProgram.use();\n\t\t\t\t\tgradientSubtractProgram.set2f(\"texelSize\", velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t\tgradientSubtractProgram.set1i(\"uPressure\", pressure.read.attach(0));\n\t\t\t\t\tgradientSubtractProgram.set1i(\"uVelocity\", velocity.read.attach(1));\n\t\t\t\t\tblit(velocity.write);\n\t\t\t\t\tvelocity.swap();\n\t\t\t\t}\n\n\t\t\t\tfunction draw() {\n\t\t\t\t\tif (!velocity || !textTexture) return;\n\t\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\t\t\t\t\tgl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n\t\t\t\t\tgl.clearColor(0, 0, 0, 0);\n\t\t\t\t\tgl.clear(gl.COLOR_BUFFER_BIT);\n\t\t\t\t\tgl.enable(gl.BLEND);\n\t\t\t\t\tgl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n\n\t\t\t\t\tdisplayProgram.use();\n\t\t\t\t\tgl.activeTexture(gl.TEXTURE0);\n\t\t\t\t\tgl.bindTexture(gl.TEXTURE_2D, textTexture);\n\t\t\t\t\tdisplayProgram.set1i(\"uText\", 0);\n\t\t\t\t\tdisplayProgram.set1i(\"uVelocity\", velocity.read.attach(1));\n\t\t\t\t\tdisplayProgram.set1f(\"strength\", live.current.strength);\n\t\t\t\t\tdisplayProgram.set1f(\"chromaticRatio\", live.current.chromaticRatio);\n\t\t\t\t\tblit(null);\n\t\t\t\t}\n\n\t\t\t\tlet rafId: number | null = requestAnimationFrame(frame);\n\t\t\t\tfunction frame() {\n\t\t\t\t\trafId = requestAnimationFrame(frame);\n\t\t\t\t\tif (live.current.pauseWhenHidden && hidden) return;\n\t\t\t\t\tstep(FIXED_DT);\n\t\t\t\t\tdraw();\n\t\t\t\t}\n\n\t\t\t\t// Tears down the running sim's listeners/GL resources. Shared\n\t\t\t\t// by the context-loss handler below (which must stop issuing\n\t\t\t\t// calls against a dead context) and the normal unmount cleanup.\n\t\t\t\tfunction teardownSim() {\n\t\t\t\t\tif (rafId !== null) {\n\t\t\t\t\t\tcancelAnimationFrame(rafId);\n\t\t\t\t\t\trafId = null;\n\t\t\t\t\t}\n\t\t\t\t\tdocument.removeEventListener(\"visibilitychange\", onVisibilityChange);\n\t\t\t\t\twindow.removeEventListener(\"pointermove\", onPointerMove);\n\t\t\t\t\tresizeObserver.disconnect();\n\t\t\t\t\tdestroySimBuffers();\n\t\t\t\t\tif (textTexture) {\n\t\t\t\t\t\tgl.deleteTexture(textTexture);\n\t\t\t\t\t\ttextTexture = null;\n\t\t\t\t\t}\n\t\t\t\t\tfor (const p of [\n\t\t\t\t\t\tadvectionProgram,\n\t\t\t\t\t\tsplatProgram,\n\t\t\t\t\t\tdiffuseProgram,\n\t\t\t\t\t\tdivergenceProgram,\n\t\t\t\t\t\tpressureProgram,\n\t\t\t\t\t\tgradientSubtractProgram,\n\t\t\t\t\t\tcopyProgram,\n\t\t\t\t\t\tdisplayProgram,\n\t\t\t\t\t]) {\n\t\t\t\t\t\tp.dispose();\n\t\t\t\t\t}\n\t\t\t\t\tdisposeGeometry();\n\t\t\t\t\trasterizeRef.current = null;\n\t\t\t\t}\n\n\t\t\t\t// A real GPU context loss (driver reset, GPU switch, too many\n\t\t\t\t// live contexts, …) must not leave the rAF loop silently\n\t\t\t\t// issuing calls against a dead context, and — without\n\t\t\t\t// preventDefault() — the context would never become eligible\n\t\t\t\t// for restoration. Recovery here is deliberately conservative:\n\t\t\t\t// every GL object created above is invalidated by the loss, so\n\t\t\t\t// rather than rebuilding the whole sim in place this drops to\n\t\t\t\t// the stable static DOM fallback for the remainder of this\n\t\t\t\t// mount.\n\t\t\t\tfunction onContextLost(event: Event) {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tteardownSim();\n\t\t\t\t\tsetMode(\"static\");\n\t\t\t\t\tstartFallbackResize();\n\t\t\t\t}\n\t\t\t\tfunction onContextRestored() {\n\t\t\t\t\t// Intentionally inert — see onContextLost above. The static\n\t\t\t\t\t// fallback stays in place rather than attempting a risky\n\t\t\t\t\t// partial rebuild of programs/geometry/FBOs in place.\n\t\t\t\t}\n\t\t\t\tcanvas.addEventListener(\"webglcontextlost\", onContextLost, false);\n\t\t\t\tcanvas.addEventListener(\"webglcontextrestored\", onContextRestored, false);\n\n\t\t\t\tdisposeOnFailure.length = 0;\n\n\t\t\t\t// Port divergence from the Svelte cleanup, which additionally\n\t\t\t\t// calls WEBGL_lose_context.loseContext() here: React runs this\n\t\t\t\t// effect setup -> cleanup -> setup on the SAME <canvas> node\n\t\t\t\t// (StrictMode in development, and Fast Refresh), and a lost\n\t\t\t\t// context is never re-acquired — getContext hands the dead one\n\t\t\t\t// back, every texture allocation returns null and the sim can\n\t\t\t\t// never restart. teardownSim() above already deletes every\n\t\t\t\t// program, texture, FBO and buffer; the context itself is\n\t\t\t\t// reclaimed with the canvas. The failure path below still loses\n\t\t\t\t// it, since there the canvas is being abandoned for this mount.\n\t\t\t\treturn () => {\n\t\t\t\t\tcanvas.removeEventListener(\"webglcontextlost\", onContextLost);\n\t\t\t\t\tcanvas.removeEventListener(\"webglcontextrestored\", onContextRestored);\n\t\t\t\t\tteardownSim();\n\t\t\t\t};\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup of whatever GL resources were already\n\t\t\t\t// allocated before the failure (a program compile/link error,\n\t\t\t\t// exhausted geometry buffers, a texture allocation failure\n\t\t\t\t// inside the initial resizeAll(), …), so a failed init doesn't\n\t\t\t\t// strand them on the canvas's still-live context. The\n\t\t\t\t// disposeOnFailure entries are each individually guarded since\n\t\t\t\t// how far setup got before throwing determines which of them\n\t\t\t\t// actually ran; the sim buffers/texture are unconditionally\n\t\t\t\t// declared above (outside this try), so they're always safe to\n\t\t\t\t// tear down here.\n\t\t\t\tfor (const dispose of disposeOnFailure) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tdispose();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t/* already gone, or the context is no longer usable */\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdestroySimBuffers();\n\t\t\t\tif (textTexture) gl.deleteTexture(textTexture);\n\t\t\t\tgl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n\t\t\t\t// Falls back to the static DOM text path rather than throwing.\n\t\t\t\tsetMode(\"static\");\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\n\t\tif (!reduceMotionQuery.matches && !tooSmall) {\n\t\t\tcleanupCanvasSim = setupCanvasSim(canvas, root, startFallbackResize);\n\t\t}\n\t\tif (!cleanupCanvasSim) {\n\t\t\t// `setMode(\"static\")` as well as the observer: setupCanvasSim's\n\t\t\t// early `!acquired` return happens before its try/catch, so it is\n\t\t\t// the one failure path that does not reset the mode itself. Without\n\t\t\t// this, an effect re-run whose GL acquisition fails after an earlier\n\t\t\t// pass already promoted to \"canvas\" would strand `mode === \"canvas\"`\n\t\t\t// — an empty canvas, no sim, and the visible text replaced by the\n\t\t\t// sr-only span. A no-op on a first mount, where the mode is already\n\t\t\t// \"static\".\n\t\t\tsetMode(\"static\");\n\t\t\tstartFallbackResize();\n\t\t}\n\n\t\t// Honor a live toggle of the OS reduced-motion setting, not just its\n\t\t// value at mount: if it flips to \"reduce\" while the sim is running,\n\t\t// tear the sim down and drop to the static fallback.\n\t\tfunction onReduceMotionChange(e: MediaQueryListEvent) {\n\t\t\tif (e.matches && cleanupCanvasSim) {\n\t\t\t\tcleanupCanvasSim();\n\t\t\t\tcleanupCanvasSim = null;\n\t\t\t\tsetMode(\"static\");\n\t\t\t\tstartFallbackResize();\n\t\t\t}\n\t\t}\n\t\treduceMotionQuery.addEventListener(\"change\", onReduceMotionChange);\n\n\t\treturn () => {\n\t\t\tthemeObserver.disconnect();\n\t\t\treduceMotionQuery.removeEventListener(\"change\", onReduceMotionChange);\n\t\t\tfallbackResizeObserver?.disconnect();\n\t\t\tcleanupCanvasSim?.();\n\t\t};\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t<div\n\t\t\tref={rootRef}\n\t\t\tclassName={cn(\"liquid-text relative block w-full\", className)}\n\t\t\tstyle={{ height: rootHeight }}\n\t\t>\n\t\t\t<canvas\n\t\t\t\tref={canvasRef}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"pointer-events-none absolute inset-0 block h-full w-full\",\n\t\t\t\t\tmode === \"canvas\" ? \"\" : \"invisible\"\n\t\t\t\t)}\n\t\t\t/>\n\t\t\t{mode === \"canvas\" ? (\n\t\t\t\t<span className=\"sr-only\">{text}</span>\n\t\t\t) : (\n\t\t\t\t<span\n\t\t\t\t\tclassName=\"liquid-text-fallback block\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tcolor: themeColor,\n\t\t\t\t\t\t// An empty family must not reach the style object: React 18's\n\t\t\t\t\t\t// hydration comparator renders it as `font-family:` and reports a\n\t\t\t\t\t\t// mismatch against the server markup that omitted it.\n\t\t\t\t\t\tfontFamily: resolvedFont || undefined,\n\t\t\t\t\t\tfontSize: `${displayFontSize}px`,\n\t\t\t\t\t\tfontWeight,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{text}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/liquid-text/LiquidText.tsx"},{"path":"react/src/components/liquid-text/index.ts","content":"\"use client\";\n\nexport { LiquidText } from \"./LiquidText\";\nexport type { LiquidTextProps } from \"./LiquidText\";\n","type":"registry:ui","target":"@ui/fancy/components/liquid-text/index.ts"},{"path":"react/src/components/liquid-text/liquid-text.css","content":".liquid-text {\n\tline-height: 1;\n}\n\n/* Anchored on the root class (the descendant selector is the port addition —\n   the Svelte compiler scoped this rule; as plain CSS `.liquid-text-fallback`\n   on its own would leak into any consumer element carrying that class).\n   See PORTING.md, styling rule 2. */\n.liquid-text .liquid-text-fallback {\n\tmargin: 0;\n\twhite-space: nowrap;\n}\n","type":"registry:ui","target":"@ui/fancy/components/liquid-text/liquid-text.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/liquid-text?framework=react"}}