{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sula-loader",
  "type": "registry:ui",
  "dependencies": [
    "clsx",
    "motion",
    "ogl",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "https://usva.build/r/sula-motion.json",
    "https://usva.build/r/sula-core.json"
  ],
  "files": [
    {
      "path": "loader-geometry.ts",
      "target": "components/ui/loader-geometry.ts",
      "type": "registry:ui",
      "content": "import { type Blob, bridgeNecks, type Neck } from \"./geometry\";\nimport { clamp01, mix, smoother, smoothstep } from \"./curves\";\n\nexport type LoaderMotion = \"orbit\" | \"cluster\" | \"twin\";\n\nexport interface LoaderFrame {\n  blobs: Blob[];\n  necks: Neck[];\n  /** The merge radius this beat wants. It rides the frame, not a constant, so a\n   * bead can thin the field to tear free and thicken it again to fuse. */\n  k: number;\n}\n\nexport interface LoaderOpts {\n  /** The loader's square side in canvas px. Every radius derives from it. */\n  size: number;\n}\n\nconst TAU = Math.PI * 2;\n\n/** Wraps any real number into [0, 1) so a raw accumulated phase is safe to index. */\nconst wrap = (t: number): number => t - Math.floor(t);\n\n/** A symmetric 0 to 1 to 0 pulse over [a, b], peaking at the midpoint. */\nconst pulse = (a: number, b: number, p: number): number =>\n  Math.sin(clamp01((p - a) / (b - a)) * Math.PI);\n\nfunction circle(cx: number, cy: number, r: number): Blob {\n  return { cx, cy, hw: r, hh: r, r };\n}\n\n/**\n * The sling: a core winds up, extrudes a droplet, snaps it free, whips it around\n * a long arc, then reaches out to swallow it with a plop. The field thins to a\n * hair while the bead flies so it reads as a separate drop, and thickens back to\n * fuse it on return. Mass is conserved: the core runs lean while the bead is\n * free and inflates past rest as it absorbs it.\n */\nexport function orbitFrame(phase: number, { size }: LoaderOpts): LoaderFrame {\n  const c = size / 2;\n  const coreR = size * 0.17;\n  const beadR = size * 0.11;\n  const p = wrap(phase);\n\n  const launch = -Math.PI * 0.35;\n  const ux = Math.cos(launch);\n  const uy = Math.sin(launch);\n\n  const emerge = smoothstep(0.1, 0.22, p);\n  const collapse = smoothstep(0.76, 0.92, p);\n  const gone = smoothstep(0.92, 1.0, p);\n\n  const f = clamp01((p - 0.22) / (0.76 - 0.22));\n  const swept = f + (0.5 * Math.sin(f * TAU)) / TAU;\n  const beadAngle = launch - TAU * 0.92 * swept;\n  const distOut = size * (0.35 + 0.02 * Math.sin(f * Math.PI));\n  const dist = mix(coreR * 0.5, distOut, emerge) * (1 - collapse);\n  const bx = c + Math.cos(beadAngle) * dist;\n  const by = c + Math.sin(beadAngle) * dist;\n  const beadRadius = beadR * emerge * (1 - gone);\n\n  const beadPresent = emerge * (1 - gone);\n  const swallow = pulse(0.86, 1.0, p);\n  const recoil = smoothstep(0.1, 0.16, p) - smoothstep(0.16, 0.3, p);\n  const antic = pulse(0.0, 0.12, p);\n  /* Reach toward the returning bead, then let go once it is swallowed. The\n   * release must finish before the loop seam, or the stretched width snaps back\n   * to rest in a single frame at the wrap. */\n  const reach = smoothstep(0.78, 0.88, p) * (1 - smoothstep(0.9, 0.98, p));\n  const lean = smoothstep(0.3, 0.6, p) * (1 - smoothstep(0.76, 0.88, p));\n\n  const coreScale = 1 - 0.12 * beadPresent + 0.14 * swallow;\n  const bearingX = Math.abs(Math.cos(beadAngle));\n  const bearingY = Math.abs(Math.sin(beadAngle));\n  const core: Blob = {\n    cx:\n      c - ux * recoil * size * 0.05 + Math.cos(beadAngle) * lean * size * 0.015,\n    cy:\n      c - uy * recoil * size * 0.05 + Math.sin(beadAngle) * lean * size * 0.015,\n    hw:\n      coreR *\n      coreScale *\n      (1 + 0.12 * reach * bearingX + 0.06 * antic * Math.abs(uy)),\n    hh:\n      coreR *\n      coreScale *\n      (1 + 0.12 * reach * bearingY + 0.06 * antic * Math.abs(ux)),\n    r: coreR * coreScale * (1 - 0.06 * antic),\n  };\n  const bead = circle(bx, by, beadRadius);\n\n  /* Fat while the bead is being born or swallowed so those fuse smoothly, thin\n   * through the free flight so the drop actually clears the core's merge field. */\n  const flight = smoothstep(0.26, 0.32, p) * (1 - smoothstep(0.72, 0.78, p));\n  const k = mix(size * 0.16, size * 0.05, flight);\n\n  const dx = bx - core.cx;\n  const dy = by - core.cy;\n  const len = Math.hypot(dx, dy) || 1;\n  const nx = dx / len;\n  const ny = dy / len;\n\n  /* Only tether across a real gap. While the bead is still buried in the core\n   * (being born, or fully swallowed) the smooth-min carries the fusion and a\n   * neck would project a capsule out past the merged surface, popping it. */\n  const gap = len - coreR - beadRadius;\n  const gapGate = smoothstep(-beadRadius, 0, gap);\n  const releaseStr = 1 - smoothstep(0.16, 0.22, p);\n  const captureStr = smoothstep(0.78, 0.86, p) * (1 - gone);\n  const strength = Math.max(releaseStr, captureStr) * gapGate;\n  if (beadRadius <= 0.5 || strength <= 0.01) {\n    return { blobs: [core, bead], necks: [], k };\n  }\n  const neck: Neck = {\n    ax: core.cx + nx * coreR,\n    ay: core.cy + ny * coreR,\n    bx: bx - nx * beadRadius,\n    by: by - ny * beadRadius,\n    r: beadRadius * 0.72,\n    strength,\n  };\n  return { blobs: [core, bead], necks: [neck], k };\n}\n\n/** Radii of the three bloom lobes, largest first, so the mass is asymmetric. */\nconst BLOOM_RADII = [0.15, 0.125, 0.105];\nconst BLOOM_ANGLES = [-Math.PI * 0.56, Math.PI * 0.14, Math.PI * 0.81];\n\n/**\n * The bloom: one mass breathes apart into a three-lobed clover and gathers back\n * into one, mitosis in reverse. The whole gesture rides a single cosine bump,\n * which is zero in both value and velocity at the loop seam, so the clover opens\n * and closes once per loop with no dwell and no jump where it wraps. The lobes\n * divide from the centre so their waists never leave bridge reach.\n */\nexport function clusterFrame(phase: number, { size }: LoaderOpts): LoaderFrame {\n  const c = size / 2;\n  const p = wrap(phase);\n  const k = size * 0.12;\n\n  const open = smoother(0.5 - 0.5 * Math.cos(p * TAU));\n  const spin = Math.sin(p * TAU) * 0.12;\n\n  const distances = [0.22, 0.24, 0.23];\n  const blobs = BLOOM_RADII.map((rf, i) => {\n    const angle = (BLOOM_ANGLES[i] as number) + spin + open * 0.12 * (i - 1);\n    const d = size * (distances[i] as number) * open;\n    const rr = size * (rf as number);\n    return {\n      cx: c + Math.cos(angle) * d,\n      cy: c + Math.sin(angle) * d,\n      hw: rr * (1 - 0.05 * open),\n      hh: rr * (1 + 0.06 * open),\n      r: rr * (1 - 0.03 * open),\n    } satisfies Blob;\n  });\n\n  /* Fade the waists out as the clover gathers: once the lobes are nearly\n   * coincident the smooth-min already fuses them, and a neck there would project\n   * an oversized capsule out of the merged circle, snapping its silhouette for a\n   * frame. Necks only exist while the lobes are genuinely apart. */\n  const merge = smoothstep(0.1, 0.5, open);\n  const [l0, l1, l2] = blobs as [Blob, Blob, Blob];\n  const necks =\n    merge <= 0.001\n      ? []\n      : [\n          ...bridgeNecks([l0, l1], k, merge),\n          ...bridgeNecks([l1, l2], k, merge),\n          ...bridgeNecks([l2, l0], k, merge),\n        ];\n  return { blobs, necks, k };\n}\n\n/**\n * The binary: two unequal masses fall around a shared centre like a binary star.\n * An elliptical separation sweeps the whole expressive range of the neck, from a\n * fused peanut at perigee to a taut glowing thread at apogee, where it snaps for\n * one breath before they slam back together. Kepler timing (fast when close,\n * slow when far) is what reads as gravity instead of a mechanical spin.\n */\nexport function twinFrame(phase: number, { size }: LoaderOpts): LoaderFrame {\n  const c = size / 2;\n  const p = wrap(phase);\n  const k = size * 0.16;\n  const bigR = size * 0.17;\n  const smallR = size * 0.12;\n\n  const theta = p * TAU + 0.18 * Math.sin(p * TAU - 0.15 * TAU) - Math.PI * 0.2;\n  const sep = size * (0.42 - 0.13 * Math.cos((p - 0.15) * TAU));\n  const dirx = Math.cos(theta);\n  const diry = Math.sin(theta);\n\n  const bigMass = bigR * bigR;\n  const smallMass = smallR * smallR;\n  const total = bigMass + smallMass;\n  const bigOff = (sep * smallMass) / total;\n  const smallOff = (sep * bigMass) / total;\n\n  const snap = 1 - smoothstep(0.55, 0.62, p);\n  const reform = smoothstep(0.72, 0.8, p);\n  const strength = p < 0.5 ? 1 : Math.max(snap, reform);\n  const closeness = clamp01(1 - (sep - size * 0.29) / (size * 0.26));\n  const stretch = strength * (1 - closeness);\n  const recoil = pulse(0.55, 0.7, p) * 0.02;\n\n  const big: Blob = {\n    cx: c - dirx * (bigOff + recoil * size),\n    cy: c - diry * (bigOff + recoil * size),\n    hw: bigR * (1 + 0.14 * stretch * Math.abs(dirx)),\n    hh: bigR * (1 + 0.14 * stretch * Math.abs(diry)),\n    r: bigR * (1 - 0.05 * stretch),\n  };\n  const small: Blob = {\n    cx: c + dirx * (smallOff + recoil * size),\n    cy: c + diry * (smallOff + recoil * size),\n    hw: smallR * (1 + 0.14 * stretch * Math.abs(dirx)),\n    hh: smallR * (1 + 0.14 * stretch * Math.abs(diry)),\n    r: smallR * (1 - 0.05 * stretch),\n  };\n  return {\n    blobs: [big, small],\n    necks: bridgeNecks([big, small], k, strength),\n    k,\n  };\n}\n\nexport const LOADER_FRAMES: Record<\n  LoaderMotion,\n  (phase: number, opts: LoaderOpts) => LoaderFrame\n> = {\n  orbit: orbitFrame,\n  cluster: clusterFrame,\n  twin: twinFrame,\n};\n\n/** Representative stills for reduced motion and the no-WebGL fallback: each\n * caught at its most legible beat (bead out, clover open, thread taut). */\nexport const STATIC_PHASES: Record<LoaderMotion, number> = {\n  orbit: 0.46,\n  cluster: 0.5,\n  twin: 0.48,\n};\n\n/** Loop period in seconds at speed 1, per motion. One legible beat per loop\n * wants slightly different pacing: the clover breathes slowest. */\nexport const LOOP_PERIODS: Record<LoaderMotion, number> = {\n  orbit: 2.6,\n  cluster: 3.0,\n  twin: 2.4,\n};\n\n/** Default period, kept for callers that do not vary pacing by motion. */\nexport const LOOP_PERIOD = LOOP_PERIODS.orbit;\n\nexport function loaderFrame(\n  motion: LoaderMotion,\n  phase: number,\n  size: number,\n): LoaderFrame {\n  return LOADER_FRAMES[motion](phase, { size });\n}\n"
    },
    {
      "path": "sula-loader.tsx",
      "target": "components/ui/sula-loader.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  createField,\n  type FieldColors,\n  resolveColor,\n  shineForBackdrop,\n} from \"./field\";\nimport { packUniforms } from \"./geometry\";\nimport { useContextRecovery } from \"./recovery\";\nimport { useFieldRetune } from \"./retune\";\nimport {\n  LOOP_PERIODS,\n  type LoaderMotion,\n  loaderFrame,\n  STATIC_PHASES,\n} from \"./loader-geometry\";\n\nexport interface SulaLoaderProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Square side in px. Defaults to 96. */\n  size?: number;\n  /** Which looping motion the droplets run. Defaults to \"orbit\". */\n  motion?: LoaderMotion;\n  /** Loop-rate multiplier; higher is faster. Defaults to 1. */\n  speed?: number;\n  /** The announced status text. Defaults to \"Loading\". */\n  label?: string;\n  /** false or reduced-motion renders a static still with no canvas. */\n  fluid?: boolean;\n  accentColor?: string;\n  backdrop?: string;\n  tint?: string;\n  shine?: number;\n}\n\nconst DEFAULT_SIZE = 96;\nconst MAX_DPR = 2;\n/** Peak surface undulation in px. A loader is always in motion, so this is a\n * constant living shimmer rather than an energy-gated one. */\nconst WOBBLE = 1.2;\n/** The slowest the loop may run, so a speed of 0 does not divide by zero. */\nconst MIN_SPEED = 0.05;\n\nfunction readColors(\n  node: HTMLElement,\n  overrides: {\n    backdrop?: string;\n    tint?: string;\n    accent?: string;\n    shine?: number;\n  },\n): FieldColors {\n  const styles = getComputedStyle(node);\n  const token = (name: string) => styles.getPropertyValue(name).trim();\n  const backdrop = resolveColor(overrides.backdrop ?? token(\"--usva-bg\"));\n  const tintToken =\n    overrides.tint ?? token(\"--usva-surface-2\") ?? token(\"--usva-surface\");\n  return {\n    backdrop,\n    tint: resolveColor(tintToken || token(\"--usva-surface\")),\n    accent: resolveColor(overrides.accent ?? token(\"--usva-accent\")),\n    shine: overrides.shine ?? shineForBackdrop(backdrop),\n  };\n}\n\nexport const SulaLoader = React.forwardRef<HTMLDivElement, SulaLoaderProps>(\n  (\n    {\n      size = DEFAULT_SIZE,\n      motion = \"orbit\",\n      speed = 1,\n      label = \"Loading\",\n      fluid = true,\n      accentColor,\n      backdrop,\n      tint,\n      shine,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduced = useReducedMotion();\n    const rootRef = React.useRef<HTMLDivElement | null>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n    const gooId = React.useId();\n\n    const { failed, generation, onContextLost, onContextReady } =\n      useContextRecovery(canvasRef);\n    const overrides = React.useMemo(\n      () => ({ backdrop, tint, accent: accentColor, shine }),\n      [backdrop, tint, accentColor, shine],\n    );\n    const overridesRef = React.useRef(overrides);\n    overridesRef.current = overrides;\n    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);\n    const [mounted, setMounted] = React.useState(false);\n    React.useEffect(() => setMounted(true), []);\n\n    /* motion and speed drive what the loop draws, not the field itself, so they\n     * ride refs instead of effect deps. Switching motion must not tear down the\n     * GL context and rebuild it on the same canvas, which races the scheduled\n     * frame against a disposed program. */\n    const motionRef = React.useRef(motion);\n    motionRef.current = motion;\n    const speedRef = React.useRef(speed);\n    speedRef.current = speed;\n\n    const isFluid = fluid && !reduced && !failed && mounted;\n\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context\n    React.useEffect(() => {\n      if (!isFluid) return;\n      const canvas = canvasRef.current;\n      const root = rootRef.current;\n      if (!canvas || !root) return;\n\n      const field = createField({\n        canvas,\n        colors: readColors(root, overridesRef.current),\n        onContextLost,\n      });\n      if (!field) {\n        onContextLost();\n        return;\n      }\n      fieldRef.current = field;\n      onContextReady();\n\n      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n      canvas.style.width = `${size}px`;\n      canvas.style.height = `${size}px`;\n      field.resize(size, size, dpr);\n\n      const start = performance.now();\n      let phase = 0;\n      let activeMotion = motionRef.current;\n      let last = start;\n      let raf = 0;\n      let killed = false;\n\n      const tick = () => {\n        if (killed) return;\n        const now = performance.now();\n        const nextMotion = motionRef.current;\n        if (nextMotion !== activeMotion) {\n          activeMotion = nextMotion;\n          phase = STATIC_PHASES[nextMotion];\n        } else {\n          const period =\n            LOOP_PERIODS[activeMotion] / Math.max(MIN_SPEED, speedRef.current);\n          phase = (phase + (now - last) / 1000 / period) % 1;\n        }\n        last = now;\n        const { blobs, necks, k } = loaderFrame(activeMotion, phase, size);\n        field.draw({\n          packed: packUniforms({ blobs, necks, k }, dpr, size),\n          k: k * dpr,\n          time: (now - start) / 1000,\n          wobble: WOBBLE,\n          alpha: 1,\n          hover: null,\n        });\n        raf = requestAnimationFrame(tick);\n      };\n\n      const stop = () => cancelAnimationFrame(raf);\n      /* Resume from a fresh timestamp so a spell paused offscreen does not jump\n       * the phase forward by the elapsed real time. */\n      const run = () => {\n        last = performance.now();\n        stop();\n        raf = requestAnimationFrame(tick);\n      };\n      let visible = true;\n      const io =\n        typeof IntersectionObserver === \"undefined\"\n          ? null\n          : new IntersectionObserver((entries) => {\n              visible = entries[0]?.isIntersecting ?? true;\n              if (visible && !document.hidden) run();\n              else stop();\n            });\n      io?.observe(root);\n\n      const onVisibility = () => {\n        if (document.hidden || !visible) stop();\n        else run();\n      };\n      document.addEventListener(\"visibilitychange\", onVisibility);\n\n      const themeObserver =\n        typeof MutationObserver === \"undefined\"\n          ? null\n          : new MutationObserver(() => {\n              field.setColors(readColors(root, overridesRef.current));\n            });\n      themeObserver?.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"data-theme\", \"class\"],\n      });\n\n      run();\n\n      return () => {\n        killed = true;\n        io?.disconnect();\n        document.removeEventListener(\"visibilitychange\", onVisibility);\n        themeObserver?.disconnect();\n        stop();\n        field.dispose();\n        fieldRef.current = null;\n        canvas.style.width = \"\";\n        canvas.style.height = \"\";\n      };\n    }, [isFluid, size, generation]);\n\n    useFieldRetune(\n      fieldRef,\n      () => (rootRef.current ? readColors(rootRef.current, overrides) : null),\n      overrides,\n    );\n\n    const still = loaderFrame(motion, STATIC_PHASES[motion], size);\n    const blur = size * 0.05;\n\n    return (\n      <div\n        ref={(node) => {\n          rootRef.current = node;\n          if (typeof forwardedRef === \"function\") forwardedRef(node);\n          else if (forwardedRef) forwardedRef.current = node;\n        }}\n        role=\"status\"\n        aria-live=\"polite\"\n        aria-busy=\"true\"\n        data-fluid={isFluid ? \"on\" : \"off\"}\n        style={{ width: size, height: size }}\n        className={cn(\n          \"relative inline-grid place-items-center text-accent\",\n          className,\n        )}\n        {...props}\n      >\n        {isFluid ? (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0\"\n          >\n            <canvas ref={canvasRef} className=\"block h-full w-full\" />\n          </div>\n        ) : (\n          <svg\n            aria-hidden=\"true\"\n            width={size}\n            height={size}\n            viewBox={`0 0 ${size} ${size}`}\n            className=\"absolute inset-0\"\n          >\n            <title>{label}</title>\n            <defs>\n              <filter id={gooId}>\n                <feGaussianBlur in=\"SourceGraphic\" stdDeviation={blur} />\n                <feColorMatrix values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7\" />\n              </filter>\n            </defs>\n            <g fill=\"currentColor\" filter={`url(#${gooId})`} opacity={0.85}>\n              {still.blobs.map((b, i) => (\n                <circle\n                  // biome-ignore lint/suspicious/noArrayIndexKey: a fixed still frame, blobs never reorder\n                  key={i}\n                  cx={b.cx}\n                  cy={b.cy}\n                  r={b.r}\n                />\n              ))}\n            </g>\n          </svg>\n        )}\n        <span className=\"sr-only\">{label}</span>\n      </div>\n    );\n  },\n);\nSulaLoader.displayName = \"SulaLoader\";\n"
    }
  ]
}