{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "DitherVeil-JS-CSS",
	"title": "DitherVeil",
	"description": "A photo printed as a 1-bit dither that the cursor burns through to full colour, leaving a trail that knits back cell by cell.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "DitherVeil.css",
			"target": "@components/DitherVeil.css",
			"content": ".dither-veil {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  touch-action: pan-y;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "DitherVeil.jsx",
			"content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture, RenderTarget } from 'ogl';\n\nimport './DitherVeil.css';\n\nconst DEFAULT_SRC = 'https://images.unsplash.com/photo-1737071371043-761e02b1ef95?q=80&w=1400&auto=format&fit=crop';\n\nconst ORDERED = { bayer: 0, noise: 1, lines: 2 };\n\nconst KERNELS = {\n  atkinson: [\n    [1, 0, 1 / 8],\n    [2, 0, 1 / 8],\n    [-1, 1, 1 / 8],\n    [0, 1, 1 / 8],\n    [1, 1, 1 / 8],\n    [0, 2, 1 / 8]\n  ],\n  floyd: [\n    [1, 0, 7 / 16],\n    [-1, 1, 3 / 16],\n    [0, 1, 5 / 16],\n    [1, 1, 1 / 16]\n  ]\n};\n\nconst MASK_SCALE = 0.5;\nconst MAX_BURSTS = 4;\nconst BURST_SECONDS = 1.2;\nconst HOLD = 1.6;\nconst INTRO_MS = 1100;\n\nconst hexToRgb = hex => {\n  let h = String(hex || '').replace('#', '');\n  if (h.length === 3) h = h.replace(/./g, c => c + c);\n  const n = parseInt(h.slice(0, 6), 16);\n  return Number.isNaN(n) ? [0, 0, 0] : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n};\n\nconst fitScale = (w, h, iw, ih, contain) => {\n  const ratio = w / h / (iw / ih);\n  if (contain) return ratio > 1 ? [ratio, 1] : [1, 1 / ratio];\n  return ratio > 1 ? [1, 1 / ratio] : [ratio, 1];\n};\n\nconst wanderAt = (t, w, h) => [\n  w * (0.5 + 0.33 * Math.sin(t * 0.53) + 0.08 * Math.sin(t * 1.31 + 0.6)),\n  h * (0.5 + 0.28 * Math.sin(t * 0.71 + 1.1) + 0.07 * Math.cos(t * 1.57))\n];\n\nconst measureEdge = (context, image) => {\n  const size = 32;\n  context.canvas.width = size;\n  context.canvas.height = size;\n  context.drawImage(image, 0, 0, size, size);\n  const data = context.getImageData(0, 0, size, size).data;\n  const sum = [0, 0, 0];\n  const squares = [0, 0, 0];\n  let count = 0;\n  for (let y = 0; y < size; y++) {\n    for (let x = 0; x < size; x++) {\n      if (x > 0 && y > 0 && x < size - 1 && y < size - 1) continue;\n      for (let c = 0; c < 3; c++) {\n        const v = data[(y * size + x) * 4 + c] / 255;\n        sum[c] += v;\n        squares[c] += v * v;\n      }\n      count++;\n    }\n  }\n  const matte = [sum[0] / count, sum[1] / count, sum[2] / count];\n  const spread =\n    matte.reduce((total, mean, c) => total + Math.sqrt(Math.max(0, squares[c] / count - mean * mean)), 0) / 3;\n  return { matte, plain: spread < 0.06 };\n};\n\nlet blueNoise = null;\n\nconst getBlueNoise = () => {\n  if (blueNoise) return blueNoise;\n  const size = 64;\n  const n = size * size;\n  const wrap = size - 1;\n  const taps = [];\n  for (let dy = -6; dy <= 6; dy++) {\n    for (let dx = -6; dx <= 6; dx++) taps.push(dx, dy, Math.exp(-(dx * dx + dy * dy) / 4.5));\n  }\n  const energy = new Float32Array(n);\n  const on = new Uint8Array(n);\n  const splat = (i, sign) => {\n    const x = i % size;\n    const y = (i - x) / size;\n    for (let k = 0; k < taps.length; k += 3) {\n      energy[((y + taps[k + 1]) & wrap) * size + ((x + taps[k]) & wrap)] += sign * taps[k + 2];\n    }\n  };\n  const find = (state, highest) => {\n    let best = 0;\n    let value = highest ? -Infinity : Infinity;\n    for (let i = 0; i < n; i++) {\n      if (on[i] !== state) continue;\n      if (highest ? energy[i] > value : energy[i] < value) {\n        value = energy[i];\n        best = i;\n      }\n    }\n    return best;\n  };\n  let seed = 7;\n  const random = () => {\n    seed = (seed * 16807) % 2147483647;\n    return seed / 2147483647;\n  };\n  const initial = Math.round(n * 0.1);\n  for (let placed = 0; placed < initial; ) {\n    const i = Math.floor(random() * n);\n    if (on[i]) continue;\n    on[i] = 1;\n    splat(i, 1);\n    placed++;\n  }\n  for (let guard = 0; guard < n; guard++) {\n    const cluster = find(1, true);\n    on[cluster] = 0;\n    splat(cluster, -1);\n    const gap = find(0, false);\n    on[gap] = 1;\n    splat(gap, 1);\n    if (gap === cluster) break;\n  }\n  const seedOn = on.slice();\n  const seedEnergy = energy.slice();\n  const rank = new Uint16Array(n);\n  for (let r = initial - 1; r >= 0; r--) {\n    const cluster = find(1, true);\n    on[cluster] = 0;\n    splat(cluster, -1);\n    rank[cluster] = r;\n  }\n  on.set(seedOn);\n  energy.set(seedEnergy);\n  for (let r = initial; r < n; r++) {\n    const gap = find(0, false);\n    on[gap] = 1;\n    splat(gap, 1);\n    rank[gap] = r;\n  }\n  const data = new Uint8Array(n * 4);\n  for (let i = 0; i < n; i++) {\n    const v = Math.floor((rank[i] / n) * 256);\n    data[i * 4] = v;\n    data[i * 4 + 1] = v;\n    data[i * 4 + 2] = v;\n    data[i * 4 + 3] = 255;\n  }\n  blueNoise = data;\n  return data;\n};\n\nconst diffuse = (pixels, cols, rows, kernel, levels, rgb, grade) => {\n  const channels = rgb ? 3 : 1;\n  const values = new Float32Array(cols * rows * channels);\n  for (let i = 0; i < cols * rows; i++) {\n    const r = pixels[i * 4] / 255;\n    const g = pixels[i * 4 + 1] / 255;\n    const b = pixels[i * 4 + 2] / 255;\n    if (rgb) {\n      values[i * 3] = grade(r);\n      values[i * 3 + 1] = grade(g);\n      values[i * 3 + 2] = grade(b);\n    } else {\n      values[i] = grade(0.2126 * r + 0.7152 * g + 0.0722 * b);\n    }\n  }\n  const steps = levels - 1;\n  const out = new Uint8Array(cols * rows * 4);\n  for (let y = 0; y < rows; y++) {\n    const dir = y & 1 ? -1 : 1;\n    for (let i = 0; i < cols; i++) {\n      const x = dir > 0 ? i : cols - 1 - i;\n      const p = y * cols + x;\n      for (let c = 0; c < channels; c++) {\n        const old = values[p * channels + c];\n        const q = Math.min(steps, Math.max(0, Math.round(old * steps))) / steps;\n        const err = old - q;\n        const byte = Math.round(q * 255);\n        if (rgb) out[p * 4 + c] = byte;\n        else out[p * 4] = out[p * 4 + 1] = out[p * 4 + 2] = byte;\n        for (let k = 0; k < kernel.length; k++) {\n          const nx = x + kernel[k][0] * dir;\n          const ny = y + kernel[k][1];\n          if (nx < 0 || nx >= cols || ny >= rows) continue;\n          values[(ny * cols + nx) * channels + c] += err * kernel[k][2];\n        }\n      }\n      out[p * 4 + 3] = 255;\n    }\n  }\n  return out;\n};\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n  vUv = uv;\n  gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst maskFragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D tPrev;\nuniform vec2 uSize;\nuniform vec2 uFrom;\nuniform vec2 uTo;\nuniform float uRadius;\nuniform float uSoftness;\nuniform float uStrength;\nuniform float uFade;\nuniform float uHold;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat strokeDistance(vec2 p, vec2 a, vec2 b) {\n  vec2 ab = b - a;\n  float h = clamp(dot(p - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0);\n  return length(p - a - ab * h);\n}\n\nvoid main() {\n  vec2 p = vec2(vUv.x, 1.0 - vUv.y) * uSize;\n  float trail = max(texture(tPrev, vUv).r - uFade, 0.0);\n  float band = max(uRadius * uSoftness, 1.0) * uHold;\n  float d = strokeDistance(p, uFrom, uTo);\n  trail = max(trail, clamp((uRadius - d) / band, 0.0, 1.0) * uStrength);\n  fragColor = vec4(trail, 0.0, 0.0, 1.0);\n}\n`;\n\nconst viewFragment = `#version 300 es\nprecision highp float;\nprecision highp int;\n\nuniform sampler2D tImage;\nuniform sampler2D tMask;\nuniform sampler2D tNoise;\nuniform sampler2D tDiffused;\nuniform vec2 uResolution;\nuniform vec2 uCover;\nuniform float uLod;\nuniform float uCell;\nuniform int uPattern;\nuniform int uPalette;\nuniform float uLevels;\nuniform vec3 uInk;\nuniform vec3 uPaper;\nuniform vec3 uRimColor;\nuniform float uRim;\nuniform float uContrast;\nuniform float uBrightness;\nuniform float uReverse;\nuniform float uIntro;\nuniform float uHold;\nuniform vec3 uMatte;\nuniform float uKey;\nuniform vec2 uSize;\nuniform vec4 uBursts[4];\nuniform float uBurstWidth;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat bayer(vec2 cell) {\n  ivec2 p = ivec2(mod(cell, 8.0));\n  int v = p.x ^ p.y;\n  int m = ((v & 1) << 5) | ((p.y & 1) << 4) | ((v & 2) << 2) | ((p.y & 2) << 1) | ((v & 4) >> 1) | ((p.y & 4) >> 2);\n  return (float(m) + 0.5) / 64.0;\n}\n\nfloat blueNoise(vec2 cell) {\n  return (texelFetch(tNoise, ivec2(mod(cell, 64.0)), 0).r * 255.0 + 0.5) / 256.0;\n}\n\nfloat engraving(vec2 cell) {\n  float period = 6.0;\n  float f = (mod(cell.x + cell.y, period) + 0.5) / period;\n  return clamp(abs(f * 2.0 - 1.0) + (bayer(cell) - 0.5) * (2.0 / period), 0.0, 1.0);\n}\n\nvec2 imageUv(vec2 uv) {\n  return (uv - 0.5) * uCover + 0.5;\n}\n\nfloat within(vec2 p) {\n  vec2 s = step(vec2(0.0), p) * step(p, vec2(1.0));\n  return s.x * s.y;\n}\n\nvec3 grade(vec3 c) {\n  return pow(clamp((c - 0.5) * uContrast + 0.5 + uBrightness, 0.0, 1.0), vec3(1.6));\n}\n\nfloat shockwave(vec2 p) {\n  float value = 0.0;\n  for (int i = 0; i < 4; i++) {\n    vec4 burst = uBursts[i];\n    if (burst.w <= 0.0) continue;\n    float offset = distance(p, burst.xy) - burst.z;\n    float edge = offset > 0.0 ? offset / (uBurstWidth * 0.35) : -offset / uBurstWidth;\n    value = max(value, clamp(1.0 - edge, 0.0, 1.0) * burst.w);\n  }\n  return value;\n}\n\nvec3 toned(vec3 c) {\n  return uPalette == 1 ? grade(c) : grade(vec3(dot(c, vec3(0.2126, 0.7152, 0.0722))));\n}\n\nvec3 quantize(vec3 v, float t) {\n  float steps = max(uLevels - 1.0, 1.0);\n  vec3 s = v * steps;\n  vec3 base = floor(s);\n  return min(base + step(vec3(t), s - base), vec3(steps)) / steps;\n}\n\nvoid main() {\n  vec2 px = vec2(gl_FragCoord.x, uResolution.y - gl_FragCoord.y);\n  vec2 cell = floor(px / uCell);\n  vec2 center = (cell + 0.5) * uCell;\n  vec2 cellUv = vec2(center.x / uResolution.x, 1.0 - center.y / uResolution.y);\n\n  vec2 sampleUv = imageUv(cellUv);\n  float framed = within(sampleUv);\n  vec3 level;\n  if (uPattern == 3) {\n    level = texelFetch(tDiffused, ivec2(cell), 0).rgb;\n  } else {\n    vec3 c = mix(uMatte, textureLod(tImage, sampleUv, uLod).rgb, framed);\n    float t = uPattern == 1 ? blueNoise(cell) : (uPattern == 2 ? engraving(cell) : bayer(cell));\n    level = quantize(toned(c), t);\n  }\n\n  vec3 color = mix(uInk, mix(uInk, uPaper, level), max(framed, uKey));\n  vec3 backdrop = mix(uInk, uPaper, toned(uMatte));\n  vec2 photoUv = imageUv(vUv);\n  vec3 raw = texture(tImage, photoUv).rgb;\n  float plain = uKey * (1.0 - smoothstep(0.05, 0.22, distance(raw, uMatte)));\n  vec3 photo = mix(mix(uInk, backdrop, uKey), mix(raw, backdrop, plain), within(photoUv));\n\n  vec2 point = vec2(cellUv.x, 1.0 - cellUv.y) * uSize;\n  float mask = max(clamp(texture(tMask, cellUv).r * uHold, 0.0, 1.0), shockwave(point));\n  float shown = mix(mask, 1.0 - mask, uReverse);\n  float order = bayer(cell.yx);\n  float low = order * (1.0 - uRim);\n  color = mix(color, uRimColor, step(low, shown) * step(0.001, uRim));\n  color = mix(color, photo, step(low + uRim, shown));\n\n  vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);\n  float spread = length((cellUv - 0.5) * aspect) / length(aspect * 0.5);\n  float appear = step(spread * 0.72 + bayer(cell + vec2(3.0, 5.0)) * 0.28, uIntro * 1.001);\n  fragColor = vec4(mix(uInk, color, appear), 1.0);\n}\n`;\n\nconst DitherVeil = ({\n  src = DEFAULT_SRC,\n  fit = 'contain',\n  pattern = 'floyd',\n  pixelSize = 2,\n  levels = 2,\n  palette = 'duotone',\n  inkColor = '#120f17',\n  paperColor = '#f4f1ea',\n  contrast = 1.15,\n  brightness = 0,\n  revealRadius = 200,\n  softness = 0.6,\n  linger = 1,\n  rimColor = '#a78bfa',\n  rim = 0,\n  reverse = false,\n  wander = false,\n  clickBurst = true,\n  className = '',\n  style\n}) => {\n  const containerRef = useRef(null);\n  const settingsRef = useRef(null);\n  const wakeRef = useRef(() => {});\n\n  useEffect(() => {\n    settingsRef.current = {\n      fit,\n      pattern,\n      pixelSize,\n      levels,\n      palette,\n      inkColor,\n      paperColor,\n      contrast,\n      brightness,\n      revealRadius,\n      softness,\n      linger,\n      rimColor,\n      rim,\n      reverse,\n      wander,\n      clickBurst\n    };\n    wakeRef.current();\n  });\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return undefined;\n\n    const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n    const renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio || 1, 2), alpha: false, antialias: false });\n    const gl = renderer.gl;\n    const canvas = gl.canvas;\n    canvas.style.display = 'block';\n    canvas.style.width = '100%';\n    canvas.style.height = '100%';\n    container.appendChild(canvas);\n\n    const floatMask = renderer.isWebgl2 && !!renderer.getExtension('EXT_color_buffer_float');\n    const geometry = new Triangle(gl);\n\n    const dataTexture = () =>\n      new Texture(gl, {\n        image: new Uint8Array(4),\n        width: 1,\n        height: 1,\n        generateMipmaps: false,\n        flipY: false,\n        minFilter: gl.NEAREST,\n        magFilter: gl.NEAREST\n      });\n    const imageTexture = new Texture(gl, { minFilter: gl.LINEAR_MIPMAP_LINEAR, magFilter: gl.LINEAR });\n    const noiseTexture = dataTexture();\n    const diffusedTexture = dataTexture();\n\n    const createMask = (w, h) =>\n      new RenderTarget(gl, {\n        width: w,\n        height: h,\n        depth: false,\n        type: floatMask ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE,\n        internalFormat: floatMask ? gl.RGBA16F : gl.RGBA,\n        minFilter: gl.LINEAR,\n        magFilter: gl.LINEAR\n      });\n    const destroyMask = target => {\n      gl.deleteFramebuffer(target.buffer);\n      gl.deleteTexture(target.texture.texture);\n    };\n    let masks = [createMask(2, 2), createMask(2, 2)];\n\n    const maskUniforms = {\n      tPrev: { value: masks[0].texture },\n      uSize: { value: [1, 1] },\n      uFrom: { value: [0, 0] },\n      uTo: { value: [0, 0] },\n      uRadius: { value: 1 },\n      uSoftness: { value: 0.5 },\n      uStrength: { value: 0 },\n      uFade: { value: 1 },\n      uHold: { value: HOLD }\n    };\n    const viewUniforms = {\n      tImage: { value: imageTexture },\n      tMask: { value: masks[0].texture },\n      tNoise: { value: noiseTexture },\n      tDiffused: { value: diffusedTexture },\n      uResolution: { value: [1, 1] },\n      uCover: { value: [1, 1] },\n      uLod: { value: 0 },\n      uCell: { value: 3 },\n      uPattern: { value: 0 },\n      uPalette: { value: 0 },\n      uLevels: { value: 2 },\n      uInk: { value: [0, 0, 0] },\n      uPaper: { value: [1, 1, 1] },\n      uRimColor: { value: [1, 1, 1] },\n      uRim: { value: 0 },\n      uContrast: { value: 1 },\n      uBrightness: { value: 0 },\n      uReverse: { value: 0 },\n      uIntro: { value: 0 },\n      uHold: { value: HOLD },\n      uMatte: { value: [0, 0, 0] },\n      uKey: { value: 0 },\n      uSize: { value: [1, 1] },\n      uBursts: { value: Array.from({ length: MAX_BURSTS * 4 }, () => 0) },\n      uBurstWidth: { value: 60 }\n    };\n    const program = (fragment, uniforms) =>\n      new Program(gl, { vertex, fragment, uniforms, depthTest: false, depthWrite: false });\n    const maskMesh = new Mesh(gl, { geometry, program: program(maskFragment, maskUniforms) });\n    const viewMesh = new Mesh(gl, { geometry, program: program(viewFragment, viewUniforms) });\n\n    const sampler = document.createElement('canvas');\n    const samplerContext = sampler.getContext('2d', { willReadFrequently: true });\n\n    let image = null;\n    let introStart = 0;\n    let diffusedKey = '';\n    let diffusionBlocked = false;\n    let width = 1;\n    let height = 1;\n    let visible = true;\n    let raf = 0;\n    let last = performance.now();\n    let trailUntil = 0;\n    let presence = 0;\n    let drift = 0;\n    const pointer = { x: 0, y: 0, inside: false, fresh: true, placed: false };\n    const brush = { x: 0, y: 0, px: 0, py: 0 };\n    const bursts = [];\n\n    const layout = () => {\n      width = Math.max(1, container.clientWidth);\n      height = Math.max(1, container.clientHeight);\n      renderer.setSize(width, height);\n      viewUniforms.uResolution.value = [canvas.width, canvas.height];\n      maskUniforms.uSize.value = [width, height];\n      viewUniforms.uSize.value = [width, height];\n      const mw = Math.max(2, Math.round(width * MASK_SCALE));\n      const mh = Math.max(2, Math.round(height * MASK_SCALE));\n      if (mw !== masks[0].width || mh !== masks[0].height) {\n        masks.forEach(destroyMask);\n        masks = [createMask(mw, mh), createMask(mw, mh)];\n      }\n      if (!pointer.placed) {\n        pointer.x = width / 2;\n        pointer.y = height / 2;\n      }\n    };\n\n    const updateDiffusion = (cell, s) => {\n      if (!image || !samplerContext) return;\n      const cols = Math.ceil(canvas.width / cell);\n      const rows = Math.ceil(canvas.height / cell);\n      const key = [\n        s.pattern,\n        s.levels,\n        s.palette,\n        s.contrast,\n        s.brightness,\n        s.fit,\n        cols,\n        rows,\n        canvas.width,\n        canvas.height\n      ].join('|');\n      if (key === diffusedKey) return;\n      diffusedKey = key;\n      const [cx, cy] = viewUniforms.uCover.value;\n      const iw = image.naturalWidth;\n      const ih = image.naturalHeight;\n      sampler.width = cols;\n      sampler.height = rows;\n      samplerContext.imageSmoothingEnabled = true;\n      samplerContext.imageSmoothingQuality = 'high';\n      const [mr, mg, mb] = viewUniforms.uMatte.value;\n      samplerContext.fillStyle = `rgb(${mr * 255}, ${mg * 255}, ${mb * 255})`;\n      samplerContext.fillRect(0, 0, cols, rows);\n      const sx = (0.5 - 0.5 * cx) * iw;\n      const sy = (0.5 - 0.5 * cy) * ih;\n      const sw = ((cols * cell) / canvas.width) * cx * iw;\n      const sh = ((rows * cell) / canvas.height) * cy * ih;\n      const x0 = Math.max(sx, 0);\n      const y0 = Math.max(sy, 0);\n      const x1 = Math.min(sx + sw, iw);\n      const y1 = Math.min(sy + sh, ih);\n      if (x1 > x0 && y1 > y0) {\n        samplerContext.drawImage(\n          image,\n          x0,\n          y0,\n          x1 - x0,\n          y1 - y0,\n          ((x0 - sx) / sw) * cols,\n          ((y0 - sy) / sh) * rows,\n          ((x1 - x0) / sw) * cols,\n          ((y1 - y0) / sh) * rows\n        );\n      }\n      const grade = v => Math.pow(Math.min(1, Math.max(0, (v - 0.5) * s.contrast + 0.5 + s.brightness)), 1.6);\n      const pixels = samplerContext.getImageData(0, 0, cols, rows).data;\n      diffusedTexture.image = diffuse(pixels, cols, rows, KERNELS[s.pattern], s.levels, s.palette === 'rgb', grade);\n      diffusedTexture.width = cols;\n      diffusedTexture.height = rows;\n      diffusedTexture.needsUpdate = true;\n    };\n\n    const frame = now => {\n      raf = 0;\n      const s = settingsRef.current;\n      if (!s) return;\n      const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));\n      last = now;\n\n      const wanderOn = s.wander && !reducedMotion;\n      let targetX = pointer.x;\n      let targetY = pointer.y;\n      if (!pointer.inside && wanderOn) {\n        drift = Math.min(1, drift + dt / 1.2);\n        const [wx, wy] = wanderAt(now / 1000, width, height);\n        const k = drift * drift * (3 - 2 * drift);\n        targetX += (wx - targetX) * k;\n        targetY += (wy - targetY) * k;\n      } else {\n        drift = 0;\n      }\n\n      presence += ((pointer.inside || wanderOn ? 1 : 0) - presence) * (1 - Math.exp(-dt / 0.16));\n\n      if (pointer.fresh) {\n        brush.x = brush.px = targetX;\n        brush.y = brush.py = targetY;\n        pointer.fresh = false;\n      } else {\n        const follow = 1 - Math.exp(-dt / 0.035);\n        brush.x += (targetX - brush.x) * follow;\n        brush.y += (targetY - brush.y) * follow;\n      }\n\n      const burstData = viewUniforms.uBursts.value;\n      burstData.fill(0);\n      for (let i = bursts.length - 1; i >= 0; i--) {\n        if ((now - bursts[i].start) / 1000 >= BURST_SECONDS) bursts.splice(i, 1);\n      }\n      bursts.forEach((b, i) => {\n        const k = Math.max(0, (now - b.start) / 1000 / BURST_SECONDS);\n        const reach =\n          Math.hypot(Math.max(b.x, width - b.x), Math.max(b.y, height - b.y)) + viewUniforms.uBurstWidth.value;\n        burstData[i * 4] = b.x;\n        burstData[i * 4 + 1] = b.y;\n        burstData[i * 4 + 2] = reach * Math.sin((k * Math.PI) / 2);\n        burstData[i * 4 + 3] = 1 - k * k * k;\n      });\n\n      if (presence > 0.002) trailUntil = now + s.linger * 1000 + 150;\n\n      const minFade = floatMask ? 0 : 1.5 / 255;\n      const fade = s.linger > 0 ? dt / s.linger : 1;\n      maskUniforms.tPrev.value = masks[0].texture;\n      maskUniforms.uFrom.value = [brush.px, brush.py];\n      maskUniforms.uTo.value = [brush.x, brush.y];\n      maskUniforms.uRadius.value = s.revealRadius * (0.45 + 0.55 * presence);\n      maskUniforms.uSoftness.value = s.softness;\n      maskUniforms.uStrength.value = presence;\n      maskUniforms.uFade.value = Math.max(fade, minFade);\n      viewUniforms.uBurstWidth.value = Math.max(60, s.revealRadius * 0.9);\n      renderer.render({ scene: maskMesh, target: masks[1] });\n      masks.reverse();\n      brush.px = brush.x;\n      brush.py = brush.y;\n\n      const cell = Math.max(1, Math.round(s.pixelSize * renderer.dpr));\n      if (image) {\n        viewUniforms.uCover.value = fitScale(\n          canvas.width,\n          canvas.height,\n          image.naturalWidth,\n          image.naturalHeight,\n          s.fit === 'contain'\n        );\n      }\n      let patternIndex = ORDERED[s.pattern] ?? 0;\n      if (KERNELS[s.pattern]) {\n        patternIndex = 0;\n        if (image && !diffusionBlocked) {\n          try {\n            updateDiffusion(cell, s);\n            patternIndex = 3;\n          } catch {\n            diffusionBlocked = true;\n          }\n        }\n      }\n      if (patternIndex === 1 && noiseTexture.width !== 64) {\n        noiseTexture.image = getBlueNoise();\n        noiseTexture.width = 64;\n        noiseTexture.height = 64;\n        noiseTexture.needsUpdate = true;\n      }\n\n      const texelsPerPixel = image ? (viewUniforms.uCover.value[0] * image.naturalWidth) / canvas.width : 1;\n      viewUniforms.tMask.value = masks[0].texture;\n      viewUniforms.uCell.value = cell;\n      viewUniforms.uLod.value = Math.log2(Math.max(cell * texelsPerPixel, 1));\n      viewUniforms.uPattern.value = patternIndex;\n      viewUniforms.uPalette.value = s.palette === 'rgb' ? 1 : 0;\n      viewUniforms.uLevels.value = Math.max(2, Math.round(s.levels));\n      viewUniforms.uInk.value = hexToRgb(s.inkColor);\n      viewUniforms.uPaper.value = hexToRgb(s.paperColor);\n      viewUniforms.uRimColor.value = hexToRgb(s.rimColor);\n      viewUniforms.uRim.value = Math.min(Math.max(s.rim, 0), 0.95);\n      viewUniforms.uContrast.value = s.contrast;\n      viewUniforms.uBrightness.value = s.brightness;\n      viewUniforms.uReverse.value = s.reverse ? 1 : 0;\n      const intro = image ? Math.min(1, (now - introStart) / INTRO_MS) : 0;\n      viewUniforms.uIntro.value = 1 - Math.pow(1 - intro, 2);\n      renderer.render({ scene: viewMesh });\n\n      const busy =\n        pointer.inside || wanderOn || presence > 0.002 || bursts.length > 0 || now < trailUntil || (image && intro < 1);\n      if (busy && visible) raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (raf || !visible) return;\n      last = performance.now();\n      raf = requestAnimationFrame(frame);\n    };\n    wakeRef.current = wake;\n\n    const img = new Image();\n    img.crossOrigin = 'anonymous';\n    img.decoding = 'async';\n    img.onload = () => {\n      image = img;\n      imageTexture.image = img;\n      try {\n        if (samplerContext) {\n          const edge = measureEdge(samplerContext, img);\n          viewUniforms.uMatte.value = edge.matte;\n          viewUniforms.uKey.value = edge.plain ? 1 : 0;\n        }\n      } catch {}\n      introStart = performance.now();\n      wake();\n    };\n    img.src = src;\n\n    const locate = e => {\n      const rect = container.getBoundingClientRect();\n      pointer.x = e.clientX - rect.left;\n      pointer.y = e.clientY - rect.top;\n      pointer.placed = true;\n    };\n    const onMove = e => {\n      locate(e);\n      if (!pointer.inside) {\n        pointer.inside = true;\n        pointer.fresh = true;\n      }\n      wake();\n    };\n    const onLeave = () => {\n      pointer.inside = false;\n      wake();\n    };\n    const onDown = e => {\n      onMove(e);\n      if (!settingsRef.current?.clickBurst || (e.pointerType === 'mouse' && e.button !== 0)) return;\n      bursts.push({ x: pointer.x, y: pointer.y, start: performance.now() });\n      if (bursts.length > MAX_BURSTS) bursts.shift();\n    };\n    container.addEventListener('pointermove', onMove, { passive: true });\n    container.addEventListener('pointerenter', onMove, { passive: true });\n    container.addEventListener('pointerdown', onDown, { passive: true });\n    container.addEventListener('pointerleave', onLeave, { passive: true });\n    container.addEventListener('pointercancel', onLeave, { passive: true });\n\n    const resizeObserver = new ResizeObserver(() => {\n      layout();\n      wake();\n    });\n    resizeObserver.observe(container);\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      wake();\n    });\n    intersectionObserver.observe(container);\n\n    layout();\n    wake();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      wakeRef.current = () => {};\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      img.onload = null;\n      container.removeEventListener('pointermove', onMove);\n      container.removeEventListener('pointerenter', onMove);\n      container.removeEventListener('pointerdown', onDown);\n      container.removeEventListener('pointerleave', onLeave);\n      container.removeEventListener('pointercancel', onLeave);\n      gl.getExtension('WEBGL_lose_context')?.loseContext();\n      if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n    };\n  }, [src]);\n\n  return <div ref={containerRef} className={`dither-veil ${className}`.trim()} style={style} />;\n};\n\nexport default DitherVeil;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"ogl@^1.0.11"
	]
}