{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mesh-gradient",
  "type": "registry:ui",
  "title": "Mesh Gradient",
  "description": "Animated WebGL mesh gradient that blends up to six colours in OKLab, with distortion, swirl, grain and a pointer lens — for heroes and empty states.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/kura-shader-color.json",
    "https://kuro.abstergo.fyi/r/shader-canvas.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/mesh-gradient/index.ts",
      "type": "registry:ui",
      "target": "components/kura/mesh-gradient/index.ts",
      "content": "export { MeshGradient, type MeshGradientProps } from \"./mesh-gradient\";\n"
    },
    {
      "path": "registry/kura/mesh-gradient/mesh-gradient.glsl.ts",
      "type": "registry:ui",
      "target": "components/kura/mesh-gradient/mesh-gradient.glsl.ts",
      "content": "import { GLSL_COLOR } from \"@/lib/kura/shader-color\";\n\n/** Max colours the shader blends. */\nexport const MAX_COLORS = 6;\n\n/**\n * Mesh gradient: up to six colour \"wells\" drift on slow Lissajous paths; every pixel blends them\n * in OKLab with inverse-distance weights (smooth, even, no muddy midpoints). The sample position\n * is domain-warped by simplex noise (distortion), twisted around the centre (swirl) and lensed\n * toward the pointer. Output is converted to sRGB once, with film grain and IGN dithering.\n */\nexport const MESH_GRADIENT_FRAG = /* glsl */ `\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec3 u_c5;\nuniform float u_count;\nuniform float u_distortion;\nuniform float u_swirl;\nuniform float u_grain;\nuniform float u_seed;\nuniform vec3 u_mouse; // xy in 0–1 (y up), z = influence 0–1\n\n${GLSL_COLOR}\n\nvec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\nfloat snoise(vec2 v) {\n  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);\n  vec2 i = floor(v + dot(v, C.yy));\n  vec2 x0 = v - i + dot(i, C.xx);\n  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n  vec4 x12 = x0.xyxy + C.xxzz;\n  x12.xy -= i1;\n  i = mod(i, 289.0);\n  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);\n  m = m * m; m = m * m;\n  vec3 x = 2.0 * fract(p * C.www) - 1.0;\n  vec3 h = abs(x) - 0.5;\n  vec3 ox = floor(x + 0.5);\n  vec3 a0 = x - ox;\n  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n  vec3 g;\n  g.x = a0.x * x0.x + h.x * x0.y;\n  g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n  return 130.0 * dot(m, g);\n}\n\nfloat hash12(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n  p3 += dot(p3, p3.yzx + 33.33);\n  return fract((p3.x + p3.y) * p3.z);\n}\n\nvec3 colorAt(int i) {\n  if (i == 0) return u_c0;\n  if (i == 1) return u_c1;\n  if (i == 2) return u_c2;\n  if (i == 3) return u_c3;\n  if (i == 4) return u_c4;\n  return u_c5;\n}\n\n// Each well orbits its own anchor on a slow, irrational Lissajous path.\nvec2 wellAt(float i, float t, float aspect) {\n  float a = i * 2.39996323 + u_seed * 1.7;\n  float ring = 0.18 + 0.2 * fract(i * 0.618034 + u_seed * 0.13);\n  vec2 anchor = vec2(0.5 * aspect, 0.5) + ring * vec2(cos(a) * aspect, sin(a)) * 1.35;\n  vec2 drift = vec2(\n    sin(t * (0.21 + 0.043 * i) + a * 1.7) * aspect,\n    cos(t * (0.17 + 0.037 * i) + a * 2.3));\n  return anchor + 0.2 * drift;\n}\n\nvoid main() {\n  float aspect = u_resolution.x / u_resolution.y;\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  vec2 p = vec2(uv.x * aspect, uv.y);\n  vec2 center = vec2(0.5 * aspect, 0.5);\n  float t = u_time * 0.35 + u_seed * 10.0;\n\n  // Pointer: a soft lens that pulls the colour field toward the cursor.\n  vec2 m = vec2(u_mouse.x * aspect, u_mouse.y);\n  vec2 dm = p - m;\n  p -= dm * 0.22 * u_mouse.z * exp(-dot(dm, dm) * 7.0);\n\n  // Distortion: low-frequency drifting simplex noise warps the sample position. A second, finer\n  // octave adds silk-like folds without turning the field into smoke.\n  vec2 q = p * 0.9;\n  vec2 warp = vec2(\n    snoise(q + vec2(t * 0.16, -t * 0.12)),\n    snoise(q + vec2(5.2 - t * 0.13, 1.3 + t * 0.15)));\n  warp += 0.28 * vec2(\n    snoise(q * 1.9 + vec2(-t * 0.21, t * 0.18) + 3.7),\n    snoise(q * 1.9 + vec2(t * 0.19, t * 0.23) + 8.1));\n  p += u_distortion * 0.22 * warp;\n\n  // Swirl: rotate around the centre, strongest near it, breathing slowly.\n  vec2 c = p - center;\n  float r = length(c);\n  float ang = u_swirl * 1.8 * exp(-r * r * 3.0) * (0.8 + 0.2 * sin(t * 0.5));\n  float cs = cos(ang), sn = sin(ang);\n  p = center + mat2(cs, -sn, sn, cs) * c;\n\n  // Inverse-distance blend in OKLab.\n  vec3 acc = vec3(0.0);\n  float wsum = 0.0;\n  for (int i = 0; i < ${MAX_COLORS}; i++) {\n    if (float(i) >= u_count) break;\n    vec2 d = p - wellAt(float(i), t, aspect);\n    float w = 1.0 / pow(dot(d, d) + 0.025, 1.75);\n    acc += colorAt(i) * w;\n    wsum += w;\n  }\n  vec3 lab = acc / wsum;\n\n  vec3 col = linearToSrgb(oklabToLinear(lab));\n\n  // Film grain: luminance-only, refreshed ~24 fps, scaled down in the deepest shadows.\n  float g = hash12(gl_FragCoord.xy + fract(floor(u_time * 24.0) * 0.1371) * 517.0) - 0.5;\n  col += g * u_grain * 0.14 * (0.35 + 0.65 * lab.x);\n\n  fragColor = vec4(dither(col), 1.0);\n}\n`;\n"
    },
    {
      "path": "registry/kura/mesh-gradient/mesh-gradient.tsx",
      "type": "registry:ui",
      "target": "components/kura/mesh-gradient/mesh-gradient.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { useResolvedColors } from \"@/lib/kura/shader-color\";\nimport { ShaderCanvas } from \"@/components/kura/shader-canvas\";\nimport { MAX_COLORS, MESH_GRADIENT_FRAG } from \"./mesh-gradient.glsl\";\n\nexport type MeshGradientPreset = \"signal\" | \"paper\" | \"jade\" | \"dusk\" | \"ember\" | \"glacier\";\n\n/**\n * Colour presets. `signal` and `paper` are built from Kura tokens and follow the theme; the rest\n * are fixed OKLCH palettes that look the same in light and dark.\n */\nexport const meshGradientPresets: Record<MeshGradientPreset, readonly string[]> = {\n  signal: [\n    \"var(--kura-canvas)\",\n    \"var(--kura-accent-subtle)\",\n    \"var(--kura-accent)\",\n    \"var(--kura-gray-3)\",\n    \"var(--kura-accent-subtle)\",\n  ],\n  paper: [\n    \"var(--kura-gray-1)\",\n    \"var(--kura-gray-4)\",\n    \"var(--kura-accent-subtle)\",\n    \"var(--kura-gray-2)\",\n    \"var(--kura-gray-6)\",\n  ],\n  jade: [\n    \"oklch(0.24 0.045 185)\",\n    \"oklch(0.5 0.105 172)\",\n    \"oklch(0.8 0.12 165)\",\n    \"oklch(0.94 0.035 140)\",\n    \"oklch(0.34 0.07 195)\",\n  ],\n  dusk: [\n    \"oklch(0.22 0.045 258)\",\n    \"oklch(0.42 0.085 248)\",\n    \"oklch(0.68 0.09 222)\",\n    \"oklch(0.88 0.06 75)\",\n    \"oklch(0.3 0.06 262)\",\n  ],\n  ember: [\n    \"oklch(0.27 0.055 35)\",\n    \"oklch(0.58 0.15 42)\",\n    \"oklch(0.8 0.13 68)\",\n    \"oklch(0.95 0.035 88)\",\n    \"oklch(0.42 0.11 30)\",\n  ],\n  glacier: [\n    \"oklch(0.975 0.008 220)\",\n    \"oklch(0.87 0.045 212)\",\n    \"oklch(0.7 0.085 205)\",\n    \"oklch(0.52 0.075 218)\",\n    \"oklch(0.93 0.03 175)\",\n  ],\n};\n\nexport interface MeshGradientProps extends React.ComponentProps<\"div\"> {\n  /** A preset name, or 2–6 CSS colours (OKLCH, hex, rgb, `var(--token)`…). Default \"signal\". */\n  colors?: MeshGradientPreset | readonly string[];\n  /** Organic warping of the colour field, 0–1. Default 0.5. */\n  distortion?: number;\n  /** Twist around the centre, 0–1. Default 0.25. */\n  swirl?: number;\n  /** Animation speed multiplier. 0 freezes the flow. Default 1. */\n  speed?: number;\n  /** Film-grain amount, 0–1. Default 0.12. Hides banding on large, low-contrast gradients. */\n  grain?: number;\n  /** Lens the gradient gently toward the pointer. Default true. */\n  interactive?: boolean;\n  /** Freeze the animation on the current frame. Default false. */\n  paused?: boolean;\n  /** Changes the starting layout of the colour wells. Default 0. */\n  seed?: number;\n  /** Device-pixel-ratio cap. Gradients are soft, so 1–1.5 is plenty. Default 1.5. */\n  maxDpr?: number;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nfunction fallbackBackground(colors: readonly string[]): string {\n  const spots = [\"18% 22%\", \"82% 18%\", \"72% 82%\", \"24% 78%\", \"50% 50%\", \"92% 55%\"];\n  const layers = colors\n    .slice(1)\n    .map((c, i) => `radial-gradient(60% 70% at ${spots[i]}, ${c}, transparent 70%)`);\n  return [...layers, colors[0]].join(\", \");\n}\n\n/**\n * Animated WebGL mesh gradient for hero sections, empty states and ambient surfaces. Blends up to\n * six colours in OKLab, with distortion, swirl, grain and a subtle pointer lens. Children render on\n * top. Falls back to layered CSS radial gradients without WebGL, and to a still frame under\n * reduced motion.\n */\nexport function MeshGradient({\n  colors = \"signal\",\n  distortion = 0.5,\n  swirl = 0.25,\n  speed = 1,\n  grain = 0.12,\n  interactive = true,\n  paused = false,\n  seed = 0,\n  maxDpr = 1.5,\n  className,\n  children,\n  ref,\n  ...props\n}: MeshGradientProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const list = React.useMemo(() => {\n    const raw = typeof colors === \"string\" ? meshGradientPresets[colors] : colors;\n    const arr = raw.length ? raw.slice(0, MAX_COLORS) : meshGradientPresets.signal;\n    return arr.length === 1 ? [arr[0]!, arr[0]!] : arr;\n  }, [colors]);\n  const { colors: resolved, version } = useResolvedColors(rootRef, list);\n\n  // Smoothed pointer (xy 0–1, y up; z = influence). Mutated, never React state.\n  const pointer = React.useRef({ x: 0.5, y: 0.5, z: 0, tx: 0.5, ty: 0.5, tz: 0, last: 0 });\n  React.useEffect(() => {\n    if (!interactive) {\n      pointer.current.tz = 0;\n      return;\n    }\n    const onMove = (e: PointerEvent) => {\n      const el = rootRef.current;\n      if (!el) return;\n      const r = el.getBoundingClientRect();\n      const x = (e.clientX - r.left) / Math.max(1, r.width);\n      const y = 1 - (e.clientY - r.top) / Math.max(1, r.height);\n      const p = pointer.current;\n      p.tx = x;\n      p.ty = y;\n      p.tz = x >= 0 && x <= 1 && y >= 0 && y <= 1 ? 1 : 0;\n    };\n    const onLeave = () => {\n      pointer.current.tz = 0;\n    };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    document.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      document.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [interactive]);\n\n  // `version` bumps when the theme flips: a new uniforms object repaints a still frame.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: version is the repaint trigger\n  const uniforms = React.useMemo(() => {\n    const color = (i: number) => () => {\n      const c = resolved.current;\n      return (c[Math.min(i, c.length - 1)]?.oklab ?? [0.5, 0, 0]) as [number, number, number];\n    };\n    return {\n      u_c0: color(0),\n      u_c1: color(1),\n      u_c2: color(2),\n      u_c3: color(3),\n      u_c4: color(4),\n      u_c5: color(5),\n      u_count: () => resolved.current.length,\n      u_distortion: clamp(distortion, 0, 1),\n      u_swirl: clamp(swirl, 0, 1),\n      u_grain: clamp(grain, 0, 1),\n      u_seed: seed,\n      u_mouse: () => {\n        const p = pointer.current;\n        const now = performance.now();\n        const dt = p.last ? Math.min(0.1, (now - p.last) / 1000) : 1;\n        p.last = now;\n        const k = 1 - Math.exp(-dt * 3.5); // ~300ms ease toward the pointer\n        p.x += (p.tx - p.x) * k;\n        p.y += (p.ty - p.y) * k;\n        p.z += (p.tz - p.z) * (1 - Math.exp(-dt * 2));\n        return [p.x, p.y, p.z] as const;\n      },\n    };\n  }, [resolved, version, distortion, swirl, grain, seed]);\n\n  const isStill = reducedMotion || paused || speed === 0;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"mesh-gradient\"\n      data-state={isStill ? \"still\" : \"animating\"}\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...props}\n    >\n      <ShaderCanvas\n        data-slot=\"mesh-gradient-canvas\"\n        fragmentShader={MESH_GRADIENT_FRAG}\n        uniforms={uniforms}\n        paused={paused || speed === 0}\n        speed={speed}\n        maxDpr={maxDpr}\n        staticTime={4 + seed}\n        className=\"-z-10 absolute inset-0\"\n        fallback={\n          <div\n            aria-hidden\n            data-slot=\"mesh-gradient-fallback\"\n            className=\"absolute inset-0\"\n            style={{ background: fallbackBackground(list) }}\n          />\n        }\n      />\n      {children}\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "effects",
    "gradient",
    "mesh",
    "webgl",
    "shader"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/mesh-gradient · Markdown for agents: https://kuro.abstergo.fyi/components/mesh-gradient.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "A hero, landing or onboarding section that needs a calm, living background behind a headline.",
        "An empty state or a 'start a conversation' panel that should feel warm instead of blank.",
        "A card or banner announcing an AI feature, using the theme-aware `signal` preset.",
        "Any container where content should sit on a soft, slowly moving colour field (pass children)."
      ],
      "whenNotToUse": [
        "Northern-lights curtains or a night-sky feel — use aurora.",
        "Showing that an agent is listening or speaking — use agent-orb.",
        "Signalling that a container is busy — use border-beam (it outlines instead of filling).",
        "Small inline indicators — use thinking-indicator or shimmer-text.",
        "Just adding texture to an existing background — use grain.",
        "A fully custom GLSL effect — build it on shader-canvas."
      ]
    }
  }
}
