{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halo-search",
  "type": "registry:ui",
  "description": "Search input with animated gradient border, staggered placeholder, blur deletion on clear, loading line, and Warp shader avatar",
  "dependencies": [
    "@paper-design/shaders-react",
    "motion"
  ],
  "files": [
    {
      "path": "registry/default/ui/halo-search.tsx",
      "content": "\"use client\"\n\n/**\n * Animated search field with gradient border polish — tuned for both light and\n * dark themes using design tokens (`card`, `border`, `foreground`, etc.).\n */\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type InputHTMLAttributes,\n} from \"react\"\nimport { Warp, type WarpProps } from \"@paper-design/shaders-react\"\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Placeholder line: snappy stagger. */\nconst PLACEHOLDER_STAGGER_SEC = 0.018\nconst PLACEHOLDER_CHAR_DURATION_SEC = 0.16\n\n/** Loading line: longer, more deliberate reveal (parent can override). */\nconst DEFAULT_LOADING_STAGGER_SEC = 0.038\nconst DEFAULT_LOADING_CHAR_DURATION_SEC = 0.36\n\n/** Clear control ↔ spinner crossfade / shared layout (ease-out ~200ms). */\nconst TRAILING_ACTION_DURATION_SEC = 0.2\n\n/** Caps right-to-left deletion stagger so long clears stay snappy and teardown stays in sync. */\nconst DELETION_MAX_STAGGER_SPAN_SEC = 0.85\n\n/**\n * Per-glyph blur timing for `DeletedTextBlurReveal` and `scheduleDeletionEnd`.\n * Compresses delay steps when `(staggerGlyphCount - 1) * step` would exceed the cap.\n */\nfunction getDeletionGlyphTiming(args: {\n  sweepDurationMs: number\n  /** Drives speed curve (same as count of glyphs that participate in the effect). */\n  animatedCount: number\n  /** Full string length used for stagger delays (max index is `length - 1`). */\n  staggerGlyphCount: number\n}) {\n  const sweepDurationSec = args.sweepDurationMs / 1000\n  const animatedCount = Math.max(1, args.animatedCount)\n  const staggerCount = Math.max(1, args.staggerGlyphCount)\n\n  const normalizedLength = Math.min(1, Math.max(0, (animatedCount - 1) / 60))\n  const easeOutLength = 1 - (1 - normalizedLength) ** 3\n  const speedFactor = 1 - 0.6 * easeOutLength\n\n  const glyphDurationSec = Math.max(\n    0.12,\n    Math.max(0.22, sweepDurationSec * 0.45) * speedFactor\n  )\n  let glyphDelayStepSec = Math.max(\n    0.004,\n    Math.max(0.012, sweepDurationSec * 0.03) * speedFactor\n  )\n\n  const gapCount = Math.max(0, staggerCount - 1)\n  const uncappedStaggerSpan = gapCount * glyphDelayStepSec\n  if (\n    uncappedStaggerSpan > DELETION_MAX_STAGGER_SPAN_SEC &&\n    uncappedStaggerSpan > 0\n  ) {\n    glyphDelayStepSec *= DELETION_MAX_STAGGER_SPAN_SEC / uncappedStaggerSpan\n  }\n\n  const staggerSpanSec = gapCount * glyphDelayStepSec\n  return { glyphDurationSec, glyphDelayStepSec, staggerSpanSec }\n}\n\ninterface HaloSearchInputProps extends InputHTMLAttributes<HTMLInputElement> {\n  onClear?: () => void\n  /**\n   * Frosted, semi-transparent fill so animated gradient blobs show through more clearly.\n   * Also applied automatically while `isLoading` (e.g. after submit until the request finishes).\n   */\n  blobTranslucent?: boolean\n  /**\n   * When true, query text is hidden and staggered loading copy is shown (same motion as\n   * the placeholder). Parent typically toggles this during async submit.\n   */\n  isLoading?: boolean\n  /** Copy shown while `isLoading`; defaults to “Searching…”. */\n  loadingText?: string\n  /** Delay between each loading character’s fade-in (seconds). Default 0.038. */\n  loadingStaggerSec?: number\n  /** Duration of each loading character’s fade-in (seconds). Default 0.36. */\n  loadingCharDurationSec?: number\n}\n\nfunction XIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={className}\n      fill=\"none\"\n      focusable=\"false\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n    >\n      <path d=\"M18 6 6 18\" />\n      <path d=\"m6 6 12 12\" />\n    </svg>\n  )\n}\n\n/** Small indeterminate ring; Tailwind `animate-spin` avoids fighting parent `layoutId` transforms. */\nfunction SearchFieldSpinner({\n  className,\n  reduceMotion,\n}: {\n  className?: string\n  reduceMotion: boolean\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex origin-center text-muted-foreground\",\n        !reduceMotion && \"animate-spin\",\n        className\n      )}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"h-3.5 w-3.5\"\n        focusable=\"false\"\n        viewBox=\"0 0 24 24\"\n      >\n        <circle\n          className=\"opacity-[0.22]\"\n          cx=\"12\"\n          cy=\"12\"\n          fill=\"none\"\n          r=\"9\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.25\"\n        />\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          fill=\"none\"\n          r=\"9\"\n          stroke=\"currentColor\"\n          strokeDasharray=\"14 42\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"2.25\"\n        />\n      </svg>\n    </span>\n  )\n}\n\nconst trailingMotionTransition = (reduceMotion: boolean) => ({\n  duration: reduceMotion ? 0 : TRAILING_ACTION_DURATION_SEC,\n  ease: \"easeOut\" as const,\n})\n\nconst trailingCrossfade = (reduceMotion: boolean) => ({\n  exit: {\n    opacity: reduceMotion ? 1 : 0,\n    scale: reduceMotion ? 1 : 0.88,\n  },\n  initial: {\n    opacity: reduceMotion ? 1 : 0,\n    scale: reduceMotion ? 1 : 0.88,\n  },\n})\n\nfunction TrailingLoadingSlot({ reduceMotion }: { reduceMotion: boolean }) {\n  const cross = trailingCrossfade(reduceMotion)\n  return (\n    <motion.div\n      animate={{ opacity: 1, scale: 1 }}\n      aria-hidden\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center rounded-full\",\n        \"pointer-events-none bg-muted text-muted-foreground shadow-foreground/10 shadow-sm ring-1 ring-foreground/10\"\n      )}\n      exit={cross.exit}\n      initial={cross.initial}\n      key=\"trailing-loading\"\n      layoutId=\"halo-search-trailing\"\n      transition={trailingMotionTransition(reduceMotion)}\n    >\n      <SearchFieldSpinner reduceMotion={reduceMotion} />\n    </motion.div>\n  )\n}\n\nfunction TrailingClearSlot({\n  reduceMotion,\n  onClear,\n}: {\n  reduceMotion: boolean\n  onClear: () => void\n}) {\n  const cross = trailingCrossfade(reduceMotion)\n  return (\n    <motion.button\n      animate={{ opacity: 1, scale: 1 }}\n      aria-label=\"Clear search\"\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center rounded-full\",\n        \"bg-muted text-muted-foreground\",\n        \"shadow-foreground/10 shadow-sm ring-1 ring-foreground/10\",\n        \"hover:bg-accent hover:text-accent-foreground\",\n        \"transition-[background-color,color] duration-150 ease-out\"\n      )}\n      exit={cross.exit}\n      initial={cross.initial}\n      key=\"trailing-clear\"\n      layoutId=\"halo-search-trailing\"\n      onClick={onClear}\n      transition={trailingMotionTransition(reduceMotion)}\n      type=\"button\"\n    >\n      <XIcon className=\"h-3.5 w-3.5\" />\n    </motion.button>\n  )\n}\n\nfunction SearchTrailingAction({\n  hasValue,\n  isLoading,\n  reduceMotion,\n  onClear,\n}: {\n  hasValue: boolean\n  isLoading: boolean\n  reduceMotion: boolean\n  onClear: () => void\n}) {\n  if (!(hasValue || isLoading)) {\n    return null\n  }\n\n  return (\n    <div className=\"relative size-6 shrink-0\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {isLoading ? (\n          <TrailingLoadingSlot reduceMotion={reduceMotion} />\n        ) : (\n          <TrailingClearSlot onClear={onClear} reduceMotion={reduceMotion} />\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/** Overlay placeholder: each character fades in with a short stagger (re-mounted via `animateKey`). */\nfunction AnimatedPlaceholder({\n  text,\n  animateKey,\n  reduceMotion,\n  staggerSec = PLACEHOLDER_STAGGER_SEC,\n  charDurationSec = PLACEHOLDER_CHAR_DURATION_SEC,\n}: {\n  text: string\n  animateKey: number\n  reduceMotion: boolean\n  /** Seconds between starting each child’s entrance. */\n  staggerSec?: number\n  /** Seconds for each glyph’s opacity fade-in. */\n  charDurationSec?: number\n}) {\n  // Keys like `a-0`, `a-1` so duplicate letters animate independently and remount cleanly.\n  const chars = useMemo(() => {\n    const counts = new Map<string, number>()\n\n    return Array.from(text, (char) => {\n      const count = counts.get(char) ?? 0\n      counts.set(char, count + 1)\n      return { char, key: `${char}-${count}` }\n    })\n  }, [text])\n\n  return (\n    <motion.span\n      animate=\"visible\"\n      className=\"pointer-events-none absolute inset-y-0 left-0 flex items-center text-muted-foreground\"\n      initial=\"hidden\"\n      key={animateKey}\n      variants={{\n        hidden: {},\n        visible: {\n          transition: {\n            staggerChildren: reduceMotion ? 0 : staggerSec,\n          },\n        },\n      }}\n    >\n      {chars.map((glyph) => (\n        <motion.span\n          className=\"inline-block whitespace-pre\"\n          key={glyph.key}\n          transition={{\n            duration: reduceMotion ? 0 : charDurationSec,\n            ease: \"easeOut\",\n          }}\n          variants={{\n            hidden: { opacity: reduceMotion ? 1 : 0 },\n            visible: { opacity: 1 },\n          }}\n        >\n          {glyph.char}\n        </motion.span>\n      ))}\n    </motion.span>\n  )\n}\n\nexport function HaloSearchInput({\n  className,\n  value,\n  onChange,\n  onClear,\n  placeholder = \"Ask anything or search\",\n  blobTranslucent = false,\n  isLoading = false,\n  loadingText = \"Searching…\",\n  loadingStaggerSec = DEFAULT_LOADING_STAGGER_SEC,\n  loadingCharDurationSec = DEFAULT_LOADING_CHAR_DURATION_SEC,\n  disabled,\n  readOnly,\n  ...props\n}: HaloSearchInputProps) {\n  const showBlobTranslucent = blobTranslucent || isLoading\n  const [internalValue, setInternalValue] = useState(\"\")\n  const [isFocused, setIsFocused] = useState(false)\n  // While true, real input text is hidden and `DeletedTextBlurReveal` shows the last string.\n  const [isDeleting, setIsDeleting] = useState(false)\n  const [deletionGhostText, setDeletionGhostText] = useState<string | null>(\n    null\n  )\n  // Which code units animate (here: entire string); could be a subset for partial effects.\n  const [deletionIndexes, setDeletionIndexes] = useState<number[]>([])\n  const [deletionKey, setDeletionKey] = useState(0)\n  // Bumps `AnimatedPlaceholder`’s React key so the stagger runs again after a clear.\n  const [placeholderAnimKey, setPlaceholderAnimKey] = useState(0)\n  const [loadingAnimKey, setLoadingAnimKey] = useState(0)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const deletionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const typingIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const [isTypingBurst, setIsTypingBurst] = useState(false)\n  const wasDeletingRef = useRef(false)\n  const wasLoadingRef = useRef(false)\n  const reduceMotion = useReducedMotion() ?? false\n  const deleteClipDurationMs = 340\n  const deleteSweepDurationMs = deleteClipDurationMs + deleteClipDurationMs / 2\n\n  // Controlled when `value` is passed; otherwise internal state mirrors the input.\n  const currentValue = value === undefined ? internalValue : value\n  const hasValue = String(currentValue).length > 0\n\n  useEffect(() => {\n    return () => {\n      if (deletionTimerRef.current) {\n        clearTimeout(deletionTimerRef.current)\n      }\n      if (typingIdleTimerRef.current) {\n        clearTimeout(typingIdleTimerRef.current)\n      }\n    }\n  }, [])\n\n  // After deletion teardown, replay placeholder entrance once the field is empty again.\n  useEffect(() => {\n    if (wasDeletingRef.current && !isDeleting && !hasValue) {\n      setPlaceholderAnimKey((k) => k + 1)\n    }\n    wasDeletingRef.current = isDeleting\n  }, [hasValue, isDeleting])\n\n  // Replay loading line stagger each time loading starts (same timing as placeholder).\n  useEffect(() => {\n    if (isLoading && !wasLoadingRef.current) {\n      setLoadingAnimKey((k) => k + 1)\n    }\n    wasLoadingRef.current = isLoading\n  }, [isLoading])\n\n  // Tear down overlay only after glyph timings finish (matches `DeletedTextBlurReveal` stagger).\n  const scheduleDeletionEnd = useCallback(\n    (deletedCount: number) => {\n      if (deletionTimerRef.current) {\n        clearTimeout(deletionTimerRef.current)\n      }\n\n      const { glyphDurationSec, staggerSpanSec } = getDeletionGlyphTiming({\n        sweepDurationMs: deleteSweepDurationMs,\n        animatedCount: deletedCount,\n        staggerGlyphCount: deletedCount,\n      })\n      const totalGlyphRunMs = (staggerSpanSec + glyphDurationSec) * 1000\n      const teardownDelayMs =\n        Math.max(deleteSweepDurationMs, totalGlyphRunMs) + 160\n\n      deletionTimerRef.current = setTimeout(() => {\n        setIsDeleting(false)\n        setDeletionGhostText(null)\n        setDeletionIndexes([])\n        deletionTimerRef.current = null\n      }, teardownDelayMs)\n    },\n    [deleteSweepDurationMs]\n  )\n\n  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n    const prevValue = String(currentValue)\n    const newLength = e.target.value.length\n    const prevLen = prevValue.length\n\n    // Full clear (e.g. select-all + delete): play exit animation, then restore empty field.\n    if (newLength === 0 && prevLen > 0 && !reduceMotion) {\n      const fullDeletionIndexes = Array.from(\n        { length: prevLen },\n        (_, idx) => idx\n      )\n      setDeletionIndexes(fullDeletionIndexes)\n      setDeletionGhostText(prevValue)\n      setDeletionKey((k) => k + 1)\n      setIsDeleting(true)\n      scheduleDeletionEnd(prevLen)\n    }\n\n    if (onChange) {\n      onChange(e)\n    } else {\n      setInternalValue(e.target.value)\n    }\n\n    const isAnimatedFullClear = newLength === 0 && prevLen > 0 && !reduceMotion\n    if (!(reduceMotion || isAnimatedFullClear)) {\n      if (typingIdleTimerRef.current) {\n        clearTimeout(typingIdleTimerRef.current)\n      }\n      setIsTypingBurst(true)\n      typingIdleTimerRef.current = setTimeout(() => {\n        setIsTypingBurst(false)\n        typingIdleTimerRef.current = null\n      }, 420)\n    }\n  }\n\n  // Mirrors the “clear to empty” path in `handleChange`, then focuses the input.\n  const handleClear = () => {\n    if (typingIdleTimerRef.current) {\n      clearTimeout(typingIdleTimerRef.current)\n      typingIdleTimerRef.current = null\n    }\n    setIsTypingBurst(false)\n    if (hasValue && !reduceMotion) {\n      const fullDeletionIndexes = Array.from(\n        { length: String(currentValue).length },\n        (_, idx) => idx\n      )\n      setDeletionIndexes(fullDeletionIndexes)\n      setDeletionGhostText(String(currentValue))\n      setDeletionKey((k) => k + 1)\n      setIsDeleting(true)\n      scheduleDeletionEnd(String(currentValue).length)\n    }\n\n    if (onClear) {\n      onClear()\n    } else {\n      setInternalValue(\"\")\n    }\n    inputRef.current?.focus()\n  }\n\n  // Border glow: warmer, faster sweep while text deletion plays (ease-out aligned with exit).\n  const borderGlow = useMemo(() => {\n    if (isDeleting) {\n      return {\n        opacityIdle: 0.88,\n        primaryBg:\n          \"linear-gradient(90deg, #fb7185, #f43f5e, #f97316, #ec4899, #a855f7)\",\n        secondaryBg:\n          \"linear-gradient(90deg, #fda4af, #fb923c, #f472b6, #fb7185)\",\n        tertiaryBg:\n          \"linear-gradient(90deg, #e11d48, #db2777, #c026d3, #9333ea)\",\n        primaryLeft: [\"-8%\", \"92%\", \"-8%\"],\n        secondaryLeft: [\"72%\", \"-2%\", \"72%\"],\n        tertiaryLeft: [\"35%\", \"62%\", \"35%\"],\n        durationMain: 2.35,\n        durationSec: 2,\n        durationTer: 1.65,\n      }\n    }\n    let idleOpacity: number\n    if (showBlobTranslucent) {\n      idleOpacity = isFocused ? 0.92 : 0.72\n    } else {\n      idleOpacity = isFocused ? 0.78 : 0.5\n    }\n    return {\n      opacityIdle: idleOpacity,\n      primaryBg: \"linear-gradient(90deg, #ff0080, #7928ca, #00d4ff, #0070f3)\",\n      secondaryBg: \"linear-gradient(90deg, #ff4d4d, #f9cb28, #ff0080)\",\n      tertiaryBg: \"linear-gradient(90deg, #0070f3, #00d4ff, #7928ca)\",\n      primaryLeft: [\"-5%\", \"75%\", \"-5%\"],\n      secondaryLeft: [\"65%\", \"10%\", \"65%\"],\n      tertiaryLeft: [\"25%\", \"55%\", \"25%\"],\n      durationMain: 6,\n      durationSec: 5,\n      durationTer: 4,\n    }\n  }, [showBlobTranslucent, isDeleting, isFocused])\n\n  // Avatar: faster pulse + shader while loading (“thinking”) vs typing burst vs idle.\n  const { avatarPulseSec, avatarWarpSpeed } = useMemo(() => {\n    if (isLoading) {\n      return { avatarPulseSec: 1.65, avatarWarpSpeed: 3.45 }\n    }\n    if (isTypingBurst) {\n      return { avatarPulseSec: 2.65, avatarWarpSpeed: 2.65 }\n    }\n    return { avatarPulseSec: 5.2, avatarWarpSpeed: 1.15 }\n  }, [isLoading, isTypingBurst])\n\n  return (\n    <div className={cn(\"relative w-full max-w-2xl\", className)}>\n      {/* Outer container for gradient border effect */}\n      <div className=\"relative rounded-full p-px\">\n        {/* Animated gradient glow layer - sits behind the input */}\n        <div className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-full\">\n          {/* Gradient container positioned at the bottom */}\n          <motion.div\n            animate={{\n              opacity: borderGlow.opacityIdle,\n            }}\n            className=\"absolute inset-x-0 bottom-0 h-1/2\"\n            transition={{ duration: 0.25, ease: \"easeOut\" }}\n          >\n            {/* Main flowing gradient blob */}\n            <motion.div\n              animate={{\n                left: borderGlow.primaryLeft,\n              }}\n              className=\"-bottom-4 absolute h-16 w-48 blur-xl\"\n              key={`g-primary-${isDeleting ? \"del\" : \"idle\"}`}\n              style={{\n                background: borderGlow.primaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationMain,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n            {/* Secondary warm accent */}\n            <motion.div\n              animate={{\n                left: borderGlow.secondaryLeft,\n              }}\n              className=\"-bottom-3 absolute h-12 w-32 blur-lg\"\n              key={`g-secondary-${isDeleting ? \"del\" : \"idle\"}`}\n              style={{\n                background: borderGlow.secondaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationSec,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n            {/* Tertiary cool accent */}\n            <motion.div\n              animate={{\n                left: borderGlow.tertiaryLeft,\n              }}\n              className=\"-bottom-2 absolute h-10 w-24 blur-lg\"\n              key={`g-tertiary-${isDeleting ? \"del\" : \"idle\"}`}\n              style={{\n                background: borderGlow.tertiaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationTer,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n          </motion.div>\n        </div>\n\n        {/* Input container — solid or frosted glass so gradient blobs read through */}\n        <div\n          aria-busy={isLoading || undefined}\n          className={cn(\n            \"relative flex items-center gap-3 rounded-full px-4 py-3\",\n            \"border border-border\",\n            \"shadow-black/5 shadow-sm dark:shadow-black/30\",\n            \"transition-[background,backdrop-filter,border-color,box-shadow] duration-200\",\n            isFocused && \"border-ring/70 dark:border-zinc-500/45\",\n            showBlobTranslucent && \"backdrop-blur-xl backdrop-saturate-150\"\n          )}\n          style={{\n            background: showBlobTranslucent\n              ? \"linear-gradient(to bottom, color-mix(in oklch, var(--card) 82%, transparent) 0%, color-mix(in oklch, var(--card) 70%, transparent) 100%)\"\n              : \"linear-gradient(to bottom, var(--card) 0%, color-mix(in oklch, var(--card) 94%, var(--muted)) 100%)\",\n          }}\n        >\n          <AiBlobWarpAvatar\n            pulseDurationSec={avatarPulseSec}\n            warpProps={{ speed: avatarWarpSpeed }}\n          />\n\n          {/* Input + animated placeholder overlay; empty `placeholder` — label text is custom. */}\n          <div className=\"relative flex-1\">\n            <input\n              {...props}\n              className={cn(\n                \"w-full bg-transparent text-base text-foreground outline-none\",\n                \"caret-foreground\",\n                \"placeholder:text-muted-foreground\",\n                (isDeleting || isLoading) &&\n                  \"text-transparent caret-transparent\"\n              )}\n              disabled={disabled}\n              onBlur={() => setIsFocused(false)}\n              onChange={handleChange}\n              onFocus={() => setIsFocused(true)}\n              placeholder=\"\"\n              readOnly={readOnly || isLoading}\n              ref={inputRef}\n              type=\"text\"\n              value={currentValue}\n            />\n            {isLoading && (\n              <AnimatedPlaceholder\n                animateKey={loadingAnimKey}\n                charDurationSec={loadingCharDurationSec}\n                reduceMotion={reduceMotion}\n                staggerSec={loadingStaggerSec}\n                text={loadingText}\n              />\n            )}\n            {!(hasValue || isDeleting || isLoading) && (\n              <AnimatedPlaceholder\n                animateKey={placeholderAnimKey}\n                reduceMotion={reduceMotion}\n                text={placeholder}\n              />\n            )}\n            {/* Deletion animation - blur reveal sweep over deleted text */}\n            <AnimatePresence>\n              {isDeleting && deletionGhostText !== null && (\n                <DeletedTextBlurReveal\n                  animatedIndexes={deletionIndexes}\n                  clipDurationMs={deleteClipDurationMs}\n                  key={deletionKey}\n                  reduceMotion={reduceMotion}\n                  showSweep={false}\n                  text={deletionGhostText}\n                  textClassName=\"text-foreground\"\n                />\n              )}\n            </AnimatePresence>\n          </div>\n\n          <SearchTrailingAction\n            hasValue={hasValue}\n            isLoading={isLoading}\n            onClear={handleClear}\n            reduceMotion={reduceMotion}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\ninterface DeletedTextBlurRevealProps {\n  text: string\n  animatedIndexes?: number[]\n  reduceMotion?: boolean\n  className?: string\n  textClassName?: string\n  clipDurationMs?: number\n  blurPx?: number\n  saturatePercent?: number\n  /** Optional diagonal blur “wiper” (disabled in the search field for a simpler exit). */\n  showSweep?: boolean\n}\n\n/**\n * Absolute overlay: clones the string as per-glyph spans, blurs/fades them on a delay\n * (right-to-left), optionally with a masked backdrop-blur sweep and soft-light noise strip.\n */\nexport function DeletedTextBlurReveal({\n  text,\n  animatedIndexes,\n  reduceMotion = false,\n  className,\n  textClassName,\n  clipDurationMs = 340,\n  blurPx = 10,\n  saturatePercent = 220,\n  showSweep = true,\n}: DeletedTextBlurRevealProps) {\n  const noiseFilterId = useId()\n  const sweepDurationMs = clipDurationMs + clipDurationMs / 2\n  // Per character + stable key; `order` is index for stagger math.\n  const glyphs = useMemo(() => {\n    const counts = new Map<string, number>()\n    let order = 0\n\n    return Array.from(text, (char) => {\n      const count = counts.get(char) ?? 0\n      counts.set(char, count + 1)\n\n      const glyph = {\n        char,\n        key: `${char}-${count}`,\n        order,\n        index: order,\n      }\n\n      order += 1\n      return glyph\n    })\n  }, [text])\n  // `null` set means animate every glyph; otherwise only indices in the set animate to blurred state.\n  const animatedIndexSet = useMemo(() => {\n    if (!animatedIndexes || animatedIndexes.length === 0) {\n      return null\n    }\n    return new Set(animatedIndexes)\n  }, [animatedIndexes])\n  const animatedCount =\n    animatedIndexes && animatedIndexes.length > 0\n      ? animatedIndexes.length\n      : glyphs.length\n  const { glyphDurationSec, glyphDelayStepSec } = getDeletionGlyphTiming({\n    sweepDurationMs,\n    animatedCount,\n    staggerGlyphCount: glyphs.length,\n  })\n\n  return (\n    <motion.div\n      aria-hidden\n      className={cn(\"pointer-events-none absolute inset-0 isolate\", className)}\n      exit={{\n        opacity: 0,\n        transition: {\n          duration: reduceMotion ? 0 : 0.12,\n          delay: reduceMotion ? 0 : 0.22,\n          ease: \"easeOut\",\n        },\n      }}\n      initial={{\n        opacity: 1,\n      }}\n      style={{\n        borderRadius: 4,\n        overflow: \"hidden\",\n      }}\n    >\n      <div className=\"absolute inset-y-0 left-0 flex w-fit max-w-full overflow-hidden\">\n        <motion.div\n          animate={{ clipPath: \"inset(0 0 0 0 round 9999px)\" }}\n          className=\"relative h-full w-fit max-w-full overflow-hidden\"\n          initial={{ clipPath: \"inset(0 0 0 0 round 9999px)\" }}\n          transition={{\n            duration: 0,\n            ease: \"easeOut\",\n          }}\n        >\n          <span\n            className={cn(\n              \"relative z-10 block min-w-0 select-none text-base text-current\",\n              textClassName\n            )}\n            style={{\n              height: \"100%\",\n              display: \"flex\",\n              alignItems: \"center\",\n              whiteSpace: \"pre\",\n            }}\n          >\n            {glyphs.map((glyph) => (\n              <motion.span\n                animate={\n                  animatedIndexSet === null || animatedIndexSet.has(glyph.index)\n                    ? {\n                        opacity: 0.04,\n                        filter: \"blur(10px)\",\n                        y: -1,\n                      }\n                    : {\n                        opacity: 1,\n                        filter: \"blur(0px)\",\n                        y: 0,\n                      }\n                }\n                className=\"inline-block\"\n                initial={{\n                  opacity: 1,\n                  filter: \"blur(0px)\",\n                  y: 0,\n                }}\n                key={glyph.key}\n                transition={{\n                  duration:\n                    reduceMotion ||\n                    (animatedIndexSet && !animatedIndexSet.has(glyph.index))\n                      ? 0\n                      : glyphDurationSec,\n                  ease: \"easeOut\",\n                  // End of string animates first (higher `order` → smaller delay expression).\n                  delay: reduceMotion\n                    ? 0\n                    : (glyphs.length - 1 - glyph.order) * glyphDelayStepSec,\n                }}\n              >\n                {glyph.char === \" \" ? \"\\u00A0\" : glyph.char}\n              </motion.span>\n            ))}\n          </span>\n\n          {/* Moving soft mask + backdrop blur; gradient mask feathers the strip edges. */}\n          {showSweep && (\n            <div className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n              <motion.div\n                animate={{ x: \"-125%\" }}\n                className=\"-right-8 absolute inset-y-0 w-24\"\n                initial={{ x: \"125%\" }}\n                style={{\n                  backdropFilter: `blur(${blurPx}px) saturate(${saturatePercent}%)`,\n                  WebkitBackdropFilter: `blur(${blurPx}px) saturate(${saturatePercent}%)`,\n                  maskImage:\n                    \"linear-gradient(to right, rgb(0 0 0 / 1) 70%, transparent)\",\n                  WebkitMaskImage:\n                    \"linear-gradient(to right, rgb(0 0 0 / 1) 70%, transparent)\",\n                }}\n                transition={{\n                  duration: reduceMotion ? 0 : sweepDurationMs / 1000,\n                  ease: \"easeOut\",\n                }}\n              />\n\n              {/* Fractal noise rect adds grain along the sweep (soft-light blend). */}\n              <motion.svg\n                animate={{ x: \"-125%\", opacity: 0 }}\n                aria-hidden=\"true\"\n                className=\"-right-8 absolute inset-y-0 h-full w-24 mix-blend-soft-light\"\n                focusable=\"false\"\n                initial={{ x: \"125%\", opacity: 0.08 }}\n                style={{\n                  maskImage:\n                    \"linear-gradient(to right, rgb(0 0 0 / 1) 72%, transparent)\",\n                  WebkitMaskImage:\n                    \"linear-gradient(to right, rgb(0 0 0 / 1) 72%, transparent)\",\n                }}\n                transition={{\n                  duration: reduceMotion ? 0 : sweepDurationMs / 1000,\n                  ease: \"easeOut\",\n                }}\n              >\n                <filter id={noiseFilterId}>\n                  <feTurbulence\n                    baseFrequency=\"0.85\"\n                    numOctaves=\"2\"\n                    stitchTiles=\"stitch\"\n                    type=\"fractalNoise\"\n                  />\n                  <feColorMatrix type=\"saturate\" values=\"0\" />\n                </filter>\n                <rect\n                  filter={`url(#${noiseFilterId})`}\n                  height=\"100%\"\n                  width=\"100%\"\n                />\n              </motion.svg>\n            </div>\n          )}\n        </motion.div>\n      </div>\n    </motion.div>\n  )\n}\n\nconst DEFAULT_WARP: Partial<WarpProps> = {\n  /** Matches idle `borderGlow` blobs: primary, secondary, tertiary gradient leading stops. */\n  // colors: [\"#ff0080\", \"#ff4d4d\", \"#0070f3\", \"#00d4ff\", \"#7928ca\"],\n  // colors: [\"#ff0080\", \"#7928ca\", \"#00d4ff\", \"#0070f3\"],\n  colors: [\"#fda4af\", \"#0070f3\", \"#fb923c\", \"#f472b6\", \"#00d4ff\"],\n  distortion: 0.25,\n  height: 720,\n  proportion: 0.54,\n  scale: 0.2,\n  shape: \"checks\",\n  shapeScale: 1,\n  softness: 1,\n  speed: 0.1,\n  swirl: 0.8,\n  swirlIterations: 10,\n  width: 1280,\n}\n\nexport type AiBlobWarpAvatarProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Scale / rotate loop length in seconds. Default 5.2. */\n  pulseDurationSec?: number\n  /** Props forwarded to the Paper `Warp` shader (merged after defaults). */\n  warpProps?: Partial<WarpProps>\n}\n\n/**\n * Circular avatar frame with an animated Warp shader inside.\n * Motion uses transforms only; shader speed pauses off-screen and when `prefers-reduced-motion` is set.\n */\nexport const AiBlobWarpAvatar = forwardRef<\n  HTMLDivElement,\n  AiBlobWarpAvatarProps\n>(function AiBlobWarpAvatarImpl(\n  { className, pulseDurationSec = 5.2, warpProps, ...props },\n  forwardedRef\n) {\n  const {\n    className: warpClassName,\n    speed: warpSpeed,\n    ...restWarpProps\n  } = warpProps ?? {}\n  const rootRef = useRef<HTMLDivElement>(null)\n  const reduceMotion = useReducedMotion()\n  const inView = useInView(rootRef, {\n    amount: 0.2,\n    margin: \"0px 0px -10% 0px\",\n  })\n  const live = inView && !reduceMotion\n\n  const setRefs = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node\n      if (typeof forwardedRef === \"function\") {\n        forwardedRef(node)\n      } else if (forwardedRef) {\n        forwardedRef.current = node\n      }\n    },\n    [forwardedRef]\n  )\n\n  const baseSpeed = warpSpeed ?? DEFAULT_WARP.speed ?? 1\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate size-8 overflow-hidden rounded-full bg-muted shadow-foreground/10 shadow-sm ring-1 ring-foreground/10\",\n        className\n      )}\n      ref={setRefs}\n      {...props}\n    >\n      <motion.div\n        animate={\n          live\n            ? {\n                scale: [1, 1.055, 0.99, 1],\n                rotate: [0, 2, -1.5, 0],\n              }\n            : { scale: 1, rotate: 0 }\n        }\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 origin-center rounded-full\"\n        transition={{\n          duration: pulseDurationSec,\n          ease: \"easeInOut\",\n          repeat: Number.POSITIVE_INFINITY,\n        }}\n      >\n        <Warp\n          {...DEFAULT_WARP}\n          {...restWarpProps}\n          speed={live ? baseSpeed : 0}\n        />\n      </motion.div>\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ]
}