"use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { useTheme } from "@exxatdesignux/ui/hooks/use-color-scheme" import { LEO_BLOB_ANIM_RATE, type LeoBlobAnimationSpeed, } from "@/lib/leo-ambience" import { cn } from "@/lib/utils" export type BlobIntensity = "high" | "normal" export type AnimatedBlobBackgroundProps = { className?: string intensity?: BlobIntensity /** When false, unmounts entirely (saves GPU while Leo is hidden). */ enabled?: boolean /** When true, blobs animate faster (thinking / analyzing). */ thinking?: boolean /** Float / think keyframes. Honours prefers-reduced-motion. */ animate?: boolean /** * Run the entrance spread + one gentle float cycle, then settle. * Used on short-lived surfaces (What's new Leo cards) where an infinite * drift would keep competing with the copy. */ playOnce?: boolean animationSpeed?: LeoBlobAnimationSpeed /** * `bar` — compact wash sized for a search / composer pill (blobs live inside * the rounded shell). `default` — full-panel hero / rail wash. */ density?: "default" | "bar" /** Soft sheen sweep (bar density + thinking only). Default true. */ sheen?: boolean /** * Free placement for bar blobs only (px). Applied on each blob’s position * shell — never on a full-bleed layer — so the pill chrome stays put. */ offsetX?: number offsetY?: number } /** * Bar lobes are sized and centred as a share of the pill width so they keep the * same overlap at any pill size. Neighbouring lobes overlap by ~20% of the pill * — enough that the wash reads as one field with three hot spots rather than * three detached circles. */ const BAR_LOBES = [ { slot: 1 as const, centerPct: 24, widthPct: 54, heightClass: "h-32" }, { slot: 3 as const, centerPct: 50, widthPct: 46, heightClass: "h-24" }, { slot: 2 as const, centerPct: 76, widthPct: 52, heightClass: "h-28" }, ] function BarBlob({ slot, centerPct, widthPct, heightClass, offsetX, offsetY, opacity, blurClass, }: { slot: 1 | 2 | 3 centerPct: number widthPct: number heightClass: string offsetX: number offsetY: number opacity: number blurClass: string }) { // Placement on the position shell; keyframes run on the core (transform). const positionStyle: React.CSSProperties = { opacity, top: "50%", left: `${centerPct}%`, width: `${widthPct}%`, transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`, } return (
) } export function AnimatedBlobBackground({ className, intensity = "normal", enabled = true, thinking = false, animate = true, playOnce = false, animationSpeed = "normal", density = "default", sheen = true, offsetX = 0, offsetY = 0, }: AnimatedBlobBackgroundProps) { const reduceMotion = useReducedMotion() ?? false const { resolvedTheme } = useTheme() const isDark = resolvedTheme === "dark" const isBar = density === "bar" if (!enabled) return null const animateFloat = animate && !reduceMotion const floatClass = playOnce ? (n: 1 | 2 | 3) => `leo-ai-blob-${n}--animate-once` : (n: 1 | 2 | 3) => `leo-ai-blob-${n}--animate` const isIntro = intensity === "high" const baseOpacity = thinking ? isDark ? 0.95 : 1 : isIntro ? 1 : isDark ? 0.55 : 0.85 const midOpacity = thinking ? isDark ? 0.9 : 0.98 : isIntro ? 1 : isDark ? 0.5 : 0.82 const tipOpacity = thinking ? isDark ? 0.92 : 1 : isIntro ? 1 : isDark ? 0.5 : 0.85 // The floor sits well under the lobes so it ties them together without // flattening the composite into a single even wash. const bridgeOpacity = midOpacity * 0.5 const blurClass = thinking || isBar ? "blur-xl" : "blur-3xl" return (
{isBar ? (
{/* Connective floor under the lobes: keeps colour continuous across the pill so the drifting lobes never separate into detached circles. */}
{BAR_LOBES.map(lobe => ( ))} {animateFloat && thinking && sheen ? (
) : null}
) : (
)}
) }