{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shader-hero-text",
  "type": "registry:ui",
  "title": "ShaderHeroText",
  "description": "Six accessible WebGL headline effects: liquid ripple, chromatic refraction, noise dissolve, wave distortion, liquid metal, and particles that follow the mouse and reform the text. Static HTML fallback, bounded motion, and no graphics runtime dependency.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json"
  ],
  "cssVars": {
    "theme": {
      "shader-hero-text-base": "var(--dt-color-foreground)",
      "shader-hero-text-accent": "var(--dt-color-primary)",
      "shader-hero-text-sheen": "var(--dt-color-muted-foreground)"
    }
  },
  "files": [
    {
      "path": "packages/components/src/components/shader-hero-text/shader-hero-text.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/shader-hero-text.tsx",
      "content": "\"use client\";\n\nimport {\n  createElement,\n  forwardRef,\n  useEffect,\n  useRef,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { createTextRenderer, type TextRenderer } from \"./renderer\";\nimport { resetParticles, stepParticles } from \"./particles\";\nimport { bounded, type ShaderHeroTextProps } from \"./types\";\n\nconst defaults = {\n  \"liquid-ripple\": 1.4,\n  \"chromatic-refraction\": 1.2,\n  \"noise-dissolve\": 1.6,\n  \"wave-distortion\": 1.4,\n  \"liquid-metal\": 1.8,\n  \"particle-follow\": 1.2,\n};\n\n/** Semantic HTML is always present; WebGL is a disposable visual enhancement. */\nexport const ShaderHeroText = forwardRef<HTMLElement, ShaderHeroTextProps>(\n  function ShaderHeroText(\n    {\n      text,\n      as = \"h1\",\n      ariaLabel,\n      animation = \"liquid-ripple\",\n      trigger = \"in-view\",\n      active = true,\n      delay = 0,\n      duration,\n      intensity = 0.5,\n      seed = 0,\n      replayKey,\n      reducedMotion = \"user\",\n      onAnimationStart,\n      onAnimationComplete,\n      className,\n      style,\n      ...props\n    },\n    forwardedRef,\n  ) {\n    const rootRef = useRef<HTMLElement | null>(null);\n    const textRef = useRef<HTMLSpanElement>(null);\n    const visualRef = useRef<HTMLSpanElement>(null);\n    const callbacks = useRef({ onAnimationStart, onAnimationComplete });\n    useEffect(() => {\n      callbacks.current = { onAnimationStart, onAnimationComplete };\n    }, [onAnimationStart, onAnimationComplete]);\n\n    useEffect(() => {\n      const root = rootRef.current,\n        textElement = textRef.current,\n        visual = visualRef.current;\n      if (!root || !textElement || !visual) return;\n      const particleMode = animation === \"particle-follow\";\n      const seconds = bounded(\n        duration,\n        defaults[animation],\n        particleMode ? 2 : 5,\n      );\n      const wait = bounded(delay, 0, 5);\n      const amount = bounded(intensity, 0.5, 1);\n      const randomSeed = Number.isFinite(seed) ? seed : 0;\n      const motion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      const contrast = window.matchMedia(\"(forced-colors: active)\");\n      const mouse = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n      let disposed = false,\n        visible = trigger !== \"in-view\",\n        fontsReady = false;\n      let failed = false,\n        completed = false,\n        started = false,\n        cycle = false,\n        selecting = false;\n      let renderer: TextRenderer | null = null,\n        canvas: HTMLCanvasElement | null = null;\n      let frame = 0,\n        lastTime = 0,\n        elapsed = 0,\n        returnElapsed = 0;\n      let pointer: [number, number] | null = null;\n      let returning = false;\n\n      const setState = (state: string, rendering = false) => {\n        root.dataset.state = state;\n        root.dataset.rendering = String(rendering && !selecting);\n      };\n      const cancelFrame = () => {\n        if (frame) cancelAnimationFrame(frame);\n        frame = 0;\n        lastTime = 0;\n        root.dataset.animating = \"false\";\n      };\n      const allowed = () =>\n        !disposed &&\n        !document.hidden &&\n        visible &&\n        fontsReady &&\n        active &&\n        reducedMotion !== \"always\" &&\n        !motion.matches &&\n        !contrast.matches &&\n        (!particleMode || mouse.matches);\n      const contextLost = (event: Event) => {\n        event.preventDefault();\n        cancelFrame();\n        renderer?.dispose();\n        renderer = null;\n        failed = true;\n        pointer = null;\n        cycle = false;\n        returning = false;\n        setState(\"fallback\");\n      };\n      const contextRestored = () => {\n        release();\n        failed = false;\n        reconcile();\n      };\n      const release = () => {\n        cancelFrame();\n        renderer?.dispose();\n        renderer = null;\n        if (canvas) {\n          canvas.removeEventListener(\"webglcontextlost\", contextLost);\n          canvas.removeEventListener(\"webglcontextrestored\", contextRestored);\n          // Free the browser context as well as its buffers when a preview leaves view.\n          const gl = canvas.getContext(\"webgl\");\n          if (gl && !gl.isContextLost())\n            gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n          canvas.remove();\n          canvas = null;\n        }\n        pointer = null;\n        returning = false;\n        cycle = false;\n        setState(completed ? \"complete\" : \"static\");\n      };\n      const finishCycle = () => {\n        returning = false;\n        setState(\"formed\", true);\n        if (cycle) {\n          cycle = false;\n          callbacks.current.onAnimationComplete?.();\n        }\n      };\n      const tick = (time: number) => {\n        frame = 0;\n        if (!allowed() || !renderer) return;\n        // Duration deadlines use elapsed time; the spring integrator separately caps its step.\n        const dt = lastTime ? Math.max(0, (time - lastTime) / 1000) : 0;\n        lastTime = time;\n        try {\n          if (renderer.particles) {\n            if (returning) returnElapsed += dt;\n            let moving = stepParticles(\n              renderer.particles,\n              pointer,\n              dt,\n              amount,\n              seconds,\n            );\n            if (returning && (seconds === 0 || returnElapsed >= seconds)) {\n              resetParticles(renderer.particles);\n              moving = false;\n            }\n            renderer.draw();\n            if (!moving) {\n              cancelFrame();\n              if (returning) finishCycle();\n              return;\n            }\n          } else {\n            elapsed += dt;\n            if (elapsed < wait) {\n              schedule();\n              return;\n            }\n            if (!started) {\n              started = true;\n              callbacks.current.onAnimationStart?.();\n            }\n            const progress =\n              seconds === 0 ? 1 : Math.min(1, (elapsed - wait) / seconds);\n            renderer.draw(progress);\n            setState(\"running\", true);\n            if (progress >= 1) {\n              completed = true;\n              release();\n              callbacks.current.onAnimationComplete?.();\n              return;\n            }\n          }\n          schedule();\n        } catch {\n          failed = true;\n          release();\n          setState(\"fallback\");\n        }\n      };\n      const schedule = () => {\n        if (!frame && allowed() && renderer) {\n          root.dataset.animating = \"true\";\n          frame = requestAnimationFrame(tick);\n        }\n      };\n      const reconcile = () => {\n        if (!allowed()) {\n          release();\n          return;\n        }\n        if (failed || completed || renderer) return;\n        canvas = document.createElement(\"canvas\");\n        canvas.className = \"absolute pointer-events-none max-w-none\";\n        canvas.setAttribute(\"aria-hidden\", \"true\");\n        canvas.addEventListener(\"webglcontextlost\", contextLost);\n        canvas.addEventListener(\"webglcontextrestored\", contextRestored);\n        visual.append(canvas);\n        try {\n          renderer = createTextRenderer(\n            canvas,\n            textElement,\n            root,\n            animation,\n            amount,\n            randomSeed,\n          );\n          if (particleMode) setState(\"formed\", true);\n          else schedule();\n        } catch {\n          failed = true;\n          release();\n          setState(\"fallback\");\n        }\n      };\n      const rebuild = () => {\n        if (disposed) return;\n        release();\n        failed = false;\n        reconcile();\n      };\n      const follow = (event: PointerEvent) => {\n        if (\n          event.pointerType !== \"mouse\" ||\n          event.buttons ||\n          selecting ||\n          !allowed() ||\n          !renderer?.particles\n        )\n          return;\n        const bounds = textElement.getBoundingClientRect();\n        pointer = [\n          event.clientX - bounds.left + renderer.mask.padding,\n          event.clientY - bounds.top + renderer.mask.padding,\n        ];\n        returning = false;\n        if (!cycle) {\n          cycle = true;\n          callbacks.current.onAnimationStart?.();\n        }\n        setState(\"following\", true);\n        schedule();\n      };\n      const leave = () => {\n        if (!renderer?.particles || !cycle) return;\n        pointer = null;\n        returning = true;\n        returnElapsed = 0;\n        setState(\"returning\", true);\n        if (seconds === 0) {\n          resetParticles(renderer.particles);\n          renderer.draw();\n          cancelFrame();\n          finishCycle();\n        } else schedule();\n      };\n      const selectionChanged = () => {\n        const selection = window.getSelection();\n        selecting =\n          !!selection &&\n          !selection.isCollapsed &&\n          selection.containsNode(textElement, true);\n        root.dataset.rendering = String(\n          !!renderer && (particleMode || started) && !selecting,\n        );\n        if (selecting) leave();\n      };\n      root.addEventListener(\"pointermove\", follow);\n      root.addEventListener(\"pointerleave\", leave);\n      root.addEventListener(\"pointercancel\", leave);\n      window.addEventListener(\"blur\", leave);\n      document.addEventListener(\"selectionchange\", selectionChanged);\n      document.addEventListener(\"visibilitychange\", reconcile);\n      [motion, contrast, mouse].forEach((query) =>\n        query.addEventListener(\"change\", reconcile),\n      );\n      const intersection =\n        typeof IntersectionObserver === \"undefined\"\n          ? null\n          : new IntersectionObserver(([entry]) => {\n              visible = !!entry?.isIntersecting;\n              reconcile();\n            });\n      intersection?.observe(root);\n      if (!intersection) visible = true;\n      const resize =\n        typeof ResizeObserver === \"undefined\"\n          ? null\n          : new ResizeObserver(rebuild);\n      resize?.observe(textElement);\n      // Observe only author-controlled styling; our data-state writes never retrigger it.\n      const styles = new MutationObserver(rebuild);\n      for (\n        let ancestor: HTMLElement | null = root;\n        ancestor;\n        ancestor = ancestor.parentElement\n      ) {\n        styles.observe(ancestor, {\n          attributes: true,\n          attributeFilter: [\n            \"class\",\n            \"style\",\n            \"dir\",\n            \"data-theme\",\n            \"data-density\",\n          ],\n        });\n      }\n      const fontsChanged = () => {\n        fontsReady = true;\n        rebuild();\n      };\n      document.fonts?.addEventListener(\"loadingdone\", fontsChanged);\n      if (document.fonts)\n        void document.fonts.ready.then(() => {\n          if (!disposed) fontsChanged();\n        });\n      else fontsReady = true;\n      reconcile();\n      return () => {\n        disposed = true;\n        intersection?.disconnect();\n        resize?.disconnect();\n        styles.disconnect();\n        root.removeEventListener(\"pointermove\", follow);\n        root.removeEventListener(\"pointerleave\", leave);\n        root.removeEventListener(\"pointercancel\", leave);\n        window.removeEventListener(\"blur\", leave);\n        document.removeEventListener(\"selectionchange\", selectionChanged);\n        document.removeEventListener(\"visibilitychange\", reconcile);\n        document.fonts?.removeEventListener(\"loadingdone\", fontsChanged);\n        [motion, contrast, mouse].forEach((query) =>\n          query.removeEventListener(\"change\", reconcile),\n        );\n        release();\n      };\n    }, [\n      as,\n      text,\n      animation,\n      trigger,\n      active,\n      delay,\n      duration,\n      intensity,\n      seed,\n      replayKey,\n      reducedMotion,\n    ]);\n\n    return createElement(\n      as,\n      {\n        ...props,\n        ref: (node: HTMLElement | null): void => {\n          rootRef.current = node;\n          if (typeof forwardedRef === \"function\") forwardedRef(node);\n          else if (forwardedRef) forwardedRef.current = node;\n        },\n        \"aria-label\": ariaLabel,\n        \"data-slot\": \"shader-hero-text\",\n        \"data-animation\": animation,\n        \"data-state\": \"static\",\n        \"data-rendering\": \"false\",\n        \"data-animating\": \"false\",\n        className: cn(\n          \"group/shader relative block min-w-0 max-w-full text-foreground\",\n          className,\n        ),\n        style: {\n          \"--shader-hero-text-base\": \"var(--dt-color-foreground)\",\n          \"--shader-hero-text-accent\": \"var(--dt-color-primary)\",\n          \"--shader-hero-text-sheen\": \"var(--dt-color-muted-foreground)\",\n          ...style,\n        } as CSSProperties,\n      },\n      <span\n        ref={textRef}\n        data-slot=\"shader-hero-text-content\"\n        className=\"motion-reduce:!text-foreground print:!text-foreground selection:bg-primary selection:text-primary-foreground block break-words whitespace-pre-wrap group-data-[rendering=true]/shader:text-transparent forced-colors:!text-[CanvasText]\"\n      >\n        {text}\n      </span>,\n      <span\n        ref={visualRef}\n        aria-hidden=\"true\"\n        data-slot=\"shader-hero-text-visual\"\n        className=\"pointer-events-none invisible absolute inset-0 group-data-[rendering=true]/shader:visible motion-reduce:!invisible print:!invisible forced-colors:!invisible\"\n      />,\n    );\n  },\n);\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/types.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/types.ts",
      "content": "import type { HTMLAttributes } from \"react\";\n\nexport const shaderHeroTextAnimations = [\n  \"liquid-ripple\",\n  \"chromatic-refraction\",\n  \"noise-dissolve\",\n  \"wave-distortion\",\n  \"liquid-metal\",\n  \"particle-follow\",\n] as const;\n\nexport type ShaderHeroTextAnimation = (typeof shaderHeroTextAnimations)[number];\nexport type ShaderHeroTextElement = \"h1\" | \"h2\" | \"p\" | \"span\";\nexport type ShaderHeroTextTrigger = \"mount\" | \"in-view\" | \"manual\";\n\nexport interface ShaderHeroTextProps extends Omit<\n  HTMLAttributes<HTMLElement>,\n  \"children\" | \"aria-label\" | \"onAnimationStart\"\n> {\n  text: string;\n  as?: ShaderHeroTextElement;\n  ariaLabel?: string;\n  animation?: ShaderHeroTextAnimation;\n  trigger?: ShaderHeroTextTrigger;\n  active?: boolean;\n  delay?: number;\n  /** Seconds. One-shot effects: 0–5; particle return: 0–2. */\n  duration?: number;\n  intensity?: number;\n  seed?: number;\n  replayKey?: string | number;\n  reducedMotion?: \"user\" | \"always\";\n  onAnimationStart?: () => void;\n  onAnimationComplete?: () => void;\n}\n\nexport function bounded(\n  value: number | undefined,\n  fallback: number,\n  max: number,\n) {\n  return Number.isFinite(value) ? Math.min(max, Math.max(0, value!)) : fallback;\n}\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/renderer.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/renderer.ts",
      "content": "import {\n  particleFragment,\n  particleVertex,\n  planeVertex,\n  textFragment,\n} from \"./shaders\";\nimport { createTextMask, readShaderColors, type TextMask } from \"./text-mask\";\nimport { sampleParticles, type ParticleField } from \"./particles\";\nimport {\n  shaderHeroTextAnimations,\n  type ShaderHeroTextAnimation,\n} from \"./types\";\n\nexport interface TextRenderer {\n  mask: TextMask;\n  particles: ParticleField | null;\n  draw: (progress?: number) => void;\n  dispose: () => void;\n}\n\nexport function createTextRenderer(\n  canvas: HTMLCanvasElement,\n  text: HTMLElement,\n  root: HTMLElement,\n  animation: ShaderHeroTextAnimation,\n  intensity: number,\n  seed: number,\n): TextRenderer {\n  const gl = canvas.getContext(\"webgl\", {\n    alpha: true,\n    premultipliedAlpha: true,\n    antialias: false,\n    depth: false,\n    stencil: false,\n    preserveDrawingBuffer: true,\n    powerPreference: \"low-power\",\n  });\n  if (!gl) throw new Error(\"WebGL unavailable\");\n  const shaders: WebGLShader[] = [];\n  let program: WebGLProgram | null = null;\n  let buffer: WebGLBuffer | null = null;\n  let texture: WebGLTexture | null = null;\n  const dispose = () => {\n    if (buffer) gl.deleteBuffer(buffer);\n    if (texture) gl.deleteTexture(texture);\n    if (program) gl.deleteProgram(program);\n    shaders.forEach((shader) => gl.deleteShader(shader));\n    buffer = texture = program = null;\n  };\n  try {\n    const mask = createTextMask(\n      text,\n      gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,\n      animation === \"particle-follow\" ? 1 : 0.25,\n    );\n    if (!mask) throw new Error(\"Unsupported text layout\");\n    canvas.width = mask.canvas.width;\n    canvas.height = mask.canvas.height;\n    canvas.style.width = `${mask.width}px`;\n    canvas.style.height = `${mask.height}px`;\n    canvas.style.left = canvas.style.top = `${-mask.padding}px`;\n    const particles =\n      animation === \"particle-follow\" ? sampleParticles(mask, seed) : null;\n    if (animation === \"particle-follow\" && !particles)\n      throw new Error(\"Insufficient glyph coverage\");\n    program = gl.createProgram();\n    if (!program) throw new Error(\"No shader program\");\n    for (const [type, source] of [\n      [gl.VERTEX_SHADER, particles ? particleVertex : planeVertex],\n      [gl.FRAGMENT_SHADER, particles ? particleFragment : textFragment],\n    ] as const) {\n      const shader = gl.createShader(type);\n      if (!shader) throw new Error(\"No shader\");\n      shaders.push(shader);\n      gl.shaderSource(shader, source);\n      gl.compileShader(shader);\n      gl.attachShader(program, shader);\n    }\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      throw new Error(\n        gl.getProgramInfoLog(program) ?? \"Shader compilation failed\",\n      );\n    }\n    gl.useProgram(program);\n    buffer = gl.createBuffer();\n    if (!buffer) throw new Error(\"No vertex buffer\");\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      particles?.positions ??\n        new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n      particles ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW,\n    );\n    const position = gl.getAttribLocation(program, \"a_position\");\n    gl.enableVertexAttribArray(position);\n    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n    const uniform = (name: string) => gl.getUniformLocation(program!, name);\n    const colors = readShaderColors(root);\n    [\"base\", \"accent\", \"sheen\"].forEach((name, index) =>\n      gl.uniform3fv(uniform(`u_${name}`), colors[index]!),\n    );\n    gl.uniform2f(\n      uniform(\"u_resolution\"),\n      particles ? mask.width : canvas.width,\n      particles ? mask.height : canvas.height,\n    );\n    gl.uniform1f(uniform(\"u_intensity\"), intensity);\n    gl.uniform1f(uniform(\"u_seed\"), seed % 1000);\n    gl.uniform1i(\n      uniform(\"u_effect\"),\n      shaderHeroTextAnimations.indexOf(animation),\n    );\n    if (particles) {\n      const limits = gl.getParameter(\n        gl.ALIASED_POINT_SIZE_RANGE,\n      ) as Float32Array;\n      gl.uniform1f(\n        uniform(\"u_size\"),\n        Math.min(limits[1]!, Math.max(limits[0]!, particles.size * mask.scale)),\n      );\n    } else {\n      texture = gl.createTexture();\n      if (!texture) throw new Error(\"No mask texture\");\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, texture);\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n      gl.texImage2D(\n        gl.TEXTURE_2D,\n        0,\n        gl.RGBA,\n        gl.RGBA,\n        gl.UNSIGNED_BYTE,\n        mask.canvas,\n      );\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n      gl.uniform1i(uniform(\"u_mask\"), 0);\n    }\n    const progressUniform = uniform(\"u_progress\");\n    gl.viewport(0, 0, canvas.width, canvas.height);\n    gl.enable(gl.BLEND);\n    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n    gl.clearColor(0, 0, 0, 0);\n    const draw = (progress = 1) => {\n      gl.clear(gl.COLOR_BUFFER_BIT);\n      gl.uniform1f(progressUniform, progress);\n      if (particles) {\n        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n        gl.bufferSubData(gl.ARRAY_BUFFER, 0, particles.positions);\n      }\n      gl.drawArrays(\n        particles ? gl.POINTS : gl.TRIANGLES,\n        0,\n        particles ? particles.positions.length / 2 : 6,\n      );\n    };\n    draw();\n    if (gl.isContextLost() || gl.getError() !== gl.NO_ERROR)\n      throw new Error(\"GPU initialization failed\");\n    return { mask, particles, draw, dispose };\n  } catch (error) {\n    dispose();\n    throw error;\n  }\n}\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/text-mask.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/text-mask.ts",
      "content": "export interface TextMask {\n  canvas: HTMLCanvasElement;\n  width: number;\n  height: number;\n  padding: number;\n  scale: number;\n  fontSize: number;\n}\n\n/** Rasterize measured, shaped line runs. Unsupported typography keeps HTML. */\nexport function createTextMask(\n  element: HTMLElement,\n  maxTextureSize: number,\n  paddingEm = 0.25,\n): TextMask | null {\n  const text = element.textContent ?? \"\";\n  const node = element.firstChild;\n  const box = element.getBoundingClientRect();\n  const css = getComputedStyle(element);\n  if (\n    !node ||\n    node.nodeType !== Node.TEXT_NODE ||\n    !text.trim() ||\n    text.length > 2000 ||\n    box.width < 1 ||\n    box.height < 1 ||\n    css.writingMode !== \"horizontal-tb\" ||\n    css.textTransform !== \"none\" ||\n    css.fontVariantCaps !== \"normal\"\n  )\n    return null;\n\n  const fontSize = parseFloat(css.fontSize);\n  const padding = Math.ceil(fontSize * paddingEm);\n  const width = box.width + padding * 2;\n  const height = box.height + padding * 2;\n  const scale = Math.min(\n    window.devicePixelRatio || 1,\n    2,\n    Math.sqrt(2_000_000 / (width * height)),\n    maxTextureSize / width,\n    maxTextureSize / height,\n  );\n  if (scale < 0.5) return null;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.max(1, Math.floor(width * scale));\n  canvas.height = Math.max(1, Math.floor(height * scale));\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.scale(scale, scale);\n  ctx.font = `${css.fontStyle} ${css.fontWeight} ${css.fontSize} ${css.fontFamily}`;\n  ctx.fontKerning = \"normal\";\n  if (css.letterSpacing !== \"normal\" && parseFloat(css.letterSpacing) !== 0) {\n    if (!(\"letterSpacing\" in ctx)) return null;\n    ctx.letterSpacing = css.letterSpacing;\n  }\n  if (css.wordSpacing !== \"normal\" && parseFloat(css.wordSpacing) !== 0) {\n    if (!(\"wordSpacing\" in ctx)) return null;\n    ctx.wordSpacing = css.wordSpacing;\n  }\n  ctx.fillStyle = \"white\";\n  ctx.textBaseline = \"alphabetic\";\n  ctx.direction = css.direction === \"rtl\" ? \"rtl\" : \"ltr\";\n  ctx.textAlign = ctx.direction === \"rtl\" ? \"right\" : \"left\";\n  const lines: {\n    start: number;\n    end: number;\n    top: number;\n    left: number;\n    right: number;\n    height: number;\n  }[] = [];\n  const range = document.createRange();\n  // DOM ranges preserve browser wrapping without splitting the canvas draw into glyphs.\n  for (let i = 0; i < text.length; i++) {\n    if (text[i] === \"\\n\") continue;\n    range.setStart(node, i);\n    range.setEnd(node, i + 1);\n    // Safari can include a zero-width caret on the previous line at a wrap.\n    // The union from getBoundingClientRect() then incorrectly spans both lines.\n    const rect = Array.from(range.getClientRects()).find(\n      (fragment) => fragment.width > 0 && fragment.height > 0,\n    );\n    if (!rect) continue;\n    const last = lines.at(-1);\n    if (\n      last &&\n      Math.abs(last.top - rect.top) < 1 &&\n      !text.slice(last.end, i).includes(\"\\n\")\n    ) {\n      last.end = i + 1;\n      last.left = Math.min(last.left, rect.left);\n      last.right = Math.max(last.right, rect.right);\n    } else {\n      lines.push({\n        start: i,\n        end: i + 1,\n        top: rect.top,\n        left: rect.left,\n        right: rect.right,\n        height: rect.height,\n      });\n    }\n  }\n  for (const line of lines) {\n    const run = text.slice(line.start, line.end);\n    const metrics = ctx.measureText(run);\n    // Avoid substituting a mismatched font/shaping engine for the real heading.\n    if (\n      Math.abs(metrics.width - (line.right - line.left)) >\n      Math.max(4, metrics.width * 0.04)\n    )\n      return null;\n    const ascent = metrics.fontBoundingBoxAscent ?? fontSize * 0.8;\n    const descent = metrics.fontBoundingBoxDescent ?? fontSize * 0.2;\n    const baseline =\n      line.top - box.top + (line.height - ascent - descent) / 2 + ascent;\n    const x = (ctx.direction === \"rtl\" ? line.right : line.left) - box.left;\n    ctx.fillText(run, x + padding, baseline + padding);\n  }\n  return { canvas, width, height, padding, scale, fontSize };\n}\n\n/** Canvas converts computed CSS colors (including oklch) to sRGB uniforms. */\nexport function readShaderColors(root: HTMLElement): number[][] {\n  const probe = document.createElement(\"span\");\n  probe.style.cssText =\n    \"position:absolute;visibility:hidden;pointer-events:none\";\n  probe.setAttribute(\"aria-hidden\", \"true\");\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = canvas.height = 1;\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true })!;\n  root.append(probe);\n  try {\n    return [\"base\", \"accent\", \"sheen\"].map((token) => {\n      probe.style.color = `var(--shader-hero-text-${token})`;\n      ctx.clearRect(0, 0, 1, 1);\n      ctx.fillStyle = getComputedStyle(probe).color;\n      ctx.fillRect(0, 0, 1, 1);\n      return Array.from(ctx.getImageData(0, 0, 1, 1).data)\n        .slice(0, 3)\n        .map((v) => v / 255);\n    });\n  } finally {\n    probe.remove();\n  }\n}\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/shaders.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/shaders.ts",
      "content": "export const planeVertex = `\nattribute vec2 a_position;\nvarying vec2 v_uv;\nvoid main() {\n  v_uv = a_position * 0.5 + 0.5;\n  gl_Position = vec4(a_position, 0.0, 1.0);\n}`;\n\nexport const textFragment = `\nprecision mediump float;\nvarying vec2 v_uv;\nuniform sampler2D u_mask;\nuniform vec2 u_resolution;\nuniform vec3 u_base, u_accent, u_sheen;\nuniform float u_progress, u_intensity, u_seed;\nuniform int u_effect;\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1,311.7)) + u_seed) * 43758.5453); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);\n  return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);\n}\nfloat mask(vec2 uv) {\n  if (uv.x<0.0 || uv.y<0.0 || uv.x>1.0 || uv.y>1.0) return 0.0;\n  return texture2D(u_mask,uv).a;\n}\nvoid main() {\n  vec2 uv = v_uv;\n  float p = u_progress;\n  float envelope = sin(p * 3.14159265);\n  float strength = u_intensity * envelope;\n  vec3 color = u_base;\n  float alpha;\n  if (u_effect == 0) {\n    vec2 q = (uv-vec2(0.5)) * vec2(u_resolution.x/u_resolution.y,1.0);\n    float r = length(q);\n    float wave = sin(r*18.0-p*15.0) * exp(-r*1.8);\n    uv += normalize(q+vec2(0.0001)) * wave * strength * 0.026;\n    color = mix(u_base,u_accent,abs(wave)*strength*0.7);\n  } else if (u_effect == 1) {\n    float band = exp(-pow((uv.x-p)*5.0,2.0));\n    float shift = band*strength*0.035;\n    uv.y += sin(uv.x*12.0-p*8.0)*shift;\n    float left = mask(uv-vec2(shift,0));\n    float center = mask(uv);\n    float right = mask(uv+vec2(shift,0));\n    alpha = max(center,max(left,right));\n    color = (u_accent*left + u_base*center + u_sheen*right)/max(left+center+right,0.001);\n    gl_FragColor = vec4(color*alpha,alpha); return;\n  } else if (u_effect == 3) {\n    uv.y += sin(uv.x*10.0-p*8.0)*strength*0.09;\n    uv.x += cos(uv.y*8.0+p*6.0)*strength*0.012;\n  }\n  alpha = mask(uv);\n  if (u_effect == 2) {\n    float n = noise(uv*vec2(95.0,45.0))*0.65 + uv.x*0.35;\n    float edge = p*1.2-0.1-n;\n    float reveal = smoothstep(-0.025,0.025,edge);\n    alpha *= reveal;\n    color = mix(u_base,u_accent,(1.0-smoothstep(0.02,0.13,edge))*u_intensity);\n  } else if (u_effect == 4) {\n    vec2 pixel = 1.0/u_resolution;\n    vec2 gradient = vec2(mask(uv+vec2(pixel.x*2.0,0))-mask(uv-vec2(pixel.x*2.0,0)),\n      mask(uv+vec2(0,pixel.y*2.0))-mask(uv-vec2(0,pixel.y*2.0)));\n    float warped = uv.x*6.0+uv.y*2.0+noise(uv*5.0)*1.8 + gradient.x*0.7;\n    float stripe = sin(warped*6.28318-p*9.0);\n    float specular = pow(max(0.0,stripe),10.0);\n    vec3 metal = mix(u_base,u_accent,smoothstep(-0.7,0.3,stripe));\n    metal = mix(metal,u_sheen,specular);\n    color = mix(u_base,metal,envelope*min(1.0,u_intensity*1.6));\n  }\n  gl_FragColor = vec4(color*alpha,alpha);\n}`;\n\nexport const particleVertex = `\nattribute vec2 a_position;\nuniform vec2 u_resolution;\nuniform float u_size;\nvoid main() {\n  vec2 uv = a_position / u_resolution;\n  gl_Position = vec4(uv.x*2.0-1.0,1.0-uv.y*2.0,0.0,1.0);\n  gl_PointSize = u_size;\n}`;\n\nexport const particleFragment = `\nprecision mediump float;\nuniform vec3 u_base;\nvoid main() {\n  float alpha = 1.0-smoothstep(0.30,0.5,length(gl_PointCoord-vec2(0.5)));\n  gl_FragColor = vec4(u_base*alpha,alpha);\n}`;\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/particles.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/particles.ts",
      "content": "import type { TextMask } from \"./text-mask\";\n\nexport interface ParticleField {\n  home: Float32Array;\n  positions: Float32Array;\n  velocity: Float32Array;\n  offsets: Float32Array;\n  size: number;\n  radius: number;\n  lastPointer: readonly [number, number] | null;\n  direction: [number, number];\n}\n\nexport function sampleParticles(\n  mask: TextMask,\n  seed: number,\n): ParticleField | null {\n  const { canvas, scale } = mask;\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;\n  let filled = 0;\n  for (let i = 3; i < data.length; i += 4) if (data[i]! > 100) filled++;\n  // Uniform sampling keeps the last word as well covered as the first.\n  const step = Math.max(1.5 * scale, Math.sqrt(filled / 3000));\n  const homes: number[] = [];\n  for (let y = step / 2; y < canvas.height; y += step) {\n    for (let x = step / 2; x < canvas.width; x += step) {\n      if (data[(Math.floor(y) * canvas.width + Math.floor(x)) * 4 + 3]! > 100) {\n        homes.push(x / scale, y / scale);\n      }\n    }\n  }\n  if (homes.length < 20 || homes.length > 16_000) return null;\n  const home = new Float32Array(homes);\n  const offsets = new Float32Array(homes.length);\n  let random = seed >>> 0;\n  for (let i = 0; i < offsets.length; i++) {\n    random = (Math.imul(random, 1664525) + 1013904223) >>> 0;\n    offsets[i] = (random / 4294967296 - 0.5) * mask.fontSize * 0.7;\n  }\n  return {\n    home,\n    positions: home.slice(),\n    velocity: new Float32Array(home.length),\n    offsets,\n    size: Math.max(1, (step / scale) * 0.9),\n    radius: Math.min(160, Math.max(64, mask.fontSize * 1.8)),\n    lastPointer: null,\n    direction: [1, 0],\n  };\n}\n\nexport function resetParticles(field: ParticleField) {\n  field.positions.set(field.home);\n  field.velocity.fill(0);\n  field.lastPointer = null;\n  field.direction[0] = 1;\n  field.direction[1] = 0;\n}\n\n/** Exact critically damped spring step; no per-frame React updates or allocations. */\nexport function stepParticles(\n  field: ParticleField,\n  pointer: readonly [number, number] | null,\n  dt: number,\n  intensity: number,\n  returnDuration: number,\n) {\n  const seconds = Math.min(0.05, Math.max(0, dt));\n  if (pointer && field.lastPointer) {\n    const dx = pointer[0] - field.lastPointer[0];\n    const dy = pointer[1] - field.lastPointer[1];\n    const travel = Math.hypot(dx, dy);\n    if (travel > 0.5) {\n      field.direction[0] = dx / travel;\n      field.direction[1] = dy / travel;\n    }\n  }\n  field.lastPointer = pointer;\n  let moving = false;\n  for (let i = 0; i < field.positions.length; i += 2) {\n    // Measure from the glyph, never the displaced particle: moving dots cannot\n    // recruit the rest of the headline into a growing cluster.\n    const dx = pointer ? pointer[0] - field.home[i]! : 0;\n    const dy = pointer ? pointer[1] - field.home[i + 1]! : 0;\n    const distanceFromPointer = Math.hypot(dx, dy);\n    const proximity = pointer\n      ? Math.min(1, Math.max(0, (1 - distanceFromPointer / field.radius) * 1.5))\n      : 0;\n    const influence = proximity * proximity * (3 - 2 * proximity) * intensity;\n    // Distribute nearby dots along a tapered tail opposite the travel direction.\n    // Each dot keeps a seeded place in the tail, rather than sharing one target.\n    const along = Math.min(\n      1,\n      Math.max(0, 0.5 + (field.offsets[i]! / field.radius) * 2.5),\n    );\n    const length = field.radius * 0.48 * along;\n    const spread = field.offsets[i + 1]! * 0.45 * (0.2 + along);\n    const tailX = -field.direction[0] * length - field.direction[1] * spread;\n    const tailY = -field.direction[1] * length + field.direction[0] * spread;\n    const omega = pointer\n      ? 5 + (1 - along) * 4\n      : 10 / Math.max(0.1, returnDuration);\n    const decay = Math.exp(-omega * seconds);\n    for (let axis = 0; axis < 2; axis++) {\n      const index = i + axis;\n      const drift = axis === 0 ? dx + tailX : dy + tailY;\n      const target = field.home[index]! + drift * influence;\n      const distance = field.positions[index]! - target;\n      const impulse = field.velocity[index]! + omega * distance;\n      const position = target + (distance + impulse * seconds) * decay;\n      const velocity =\n        (field.velocity[index]! - omega * impulse * seconds) * decay;\n      if (Math.abs(position - target) < 0.08 && Math.abs(velocity) < 0.15) {\n        field.positions[index] = target;\n        field.velocity[index] = 0;\n      } else {\n        moving = true;\n        field.positions[index] = position;\n        field.velocity[index] = velocity;\n      }\n    }\n  }\n  return moving;\n}\n"
    },
    {
      "path": "packages/components/src/components/shader-hero-text/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/shader-hero-text/index.ts",
      "content": "export { ShaderHeroText } from \"./shader-hero-text\";\nexport { shaderHeroTextAnimations } from \"./types\";\nexport type {\n  ShaderHeroTextAnimation,\n  ShaderHeroTextElement,\n  ShaderHeroTextTrigger,\n  ShaderHeroTextProps,\n} from \"./types\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
