{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"feedback-widget","type":"registry:block","title":"Feedback Widget","description":"Corner trigger that morphs open into a feedback popup with message entry and animated sending, success and retry states.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/feedback-widget.tsx","type":"registry:component","target":"@components/motion/feedback-widget.tsx","content":"\"use client\";\n// beui.dev/components/blocks/feedback-widget\n\nimport { AlertCircle, MessageSquare, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button, StatefulButton } from \"@/components/motion/button\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Status = \"idle\" | \"open\" | \"sending\" | \"sent\" | \"error\";\n\nconst SUCCESS_DURATION_MS = 1600;\n\n// The trigger grows into the panel with a slight overshoot on open, then uses\n// a calmer curve on close so dismissing feels faster and less prominent.\nconst MORPH_OPEN_EASE = [0.34, 1.25, 0.64, 1] as const;\nconst MORPH_CLOSE_EASE = [0.22, 1, 0.36, 1] as const;\nconst MORPH_OPEN_DURATION = 0.4;\nconst MORPH_CLOSE_DURATION = 0.28;\nconst MORPH_FADE_DURATION = 0.22;\nconst MORPH_SLIDE = 40;\nconst MORPH_SCALE = 0.97;\nconst MORPH_BLUR = \"blur(2px)\";\n\n// Celebration sprinkles that burst from the success icon.\nconst SPRINKLES = Array.from({ length: 8 }, (_, i) => {\n  const angle = (i / 8) * Math.PI * 2;\n  return {\n    x: Math.cos(angle) * 26,\n    y: Math.sin(angle) * 26,\n    color: i % 2 === 0 ? \"var(--color-success)\" : \"var(--accent)\",\n  };\n});\n\nexport interface FeedbackData {\n  message: string;\n}\n\nexport interface FeedbackWidgetProps {\n  /** Called on submit. May be async; the button shows a sending state until it resolves. */\n  onSubmit?: (data: FeedbackData) => void | Promise<void>;\n  position?: \"bottom-right\" | \"bottom-left\";\n  title?: string;\n  placeholder?: string;\n  icon?: ReactNode;\n  className?: string;\n}\n\nexport function FeedbackWidget({\n  onSubmit,\n  position = \"bottom-right\",\n  title = \"Help us improve\",\n  placeholder = \"Share an idea or report a bug\",\n  icon,\n  className,\n}: FeedbackWidgetProps) {\n  const reduce = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [message, setMessage] = useState(\"\");\n\n  const open = status !== \"idle\";\n  const busy = status === \"sending\";\n\n  const clearCloseTimer = useCallback(() => {\n    if (closeTimerRef.current === null) return;\n    clearTimeout(closeTimerRef.current);\n    closeTimerRef.current = null;\n  }, []);\n\n  const close = useCallback(() => {\n    clearCloseTimer();\n    setStatus(\"idle\");\n    setMessage(\"\");\n  }, [clearCloseTimer]);\n\n  useEffect(\n    () => () => {\n      if (closeTimerRef.current !== null) {\n        clearTimeout(closeTimerRef.current);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (status !== \"open\") return;\n\n    // Match the wallet account switcher's staged interaction: focusing while\n    // the surface is still expanding makes the caret and focus state appear\n    // inside a scaled panel. Arm the field once the open morph has settled.\n    const timer = window.setTimeout(\n      () => textareaRef.current?.focus(),\n      reduce ? 0 : MORPH_OPEN_DURATION * 1000,\n    );\n    return () => window.clearTimeout(timer);\n  }, [status, reduce]);\n\n  // Dismiss on escape or outside click while open (but not mid-send).\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && !busy) close();\n    };\n    const onPointer = (e: PointerEvent) => {\n      if (\n        !busy &&\n        rootRef.current &&\n        !rootRef.current.contains(e.target as Node)\n      ) {\n        close();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, busy, close]);\n\n  const scheduleSuccessClose = () => {\n    clearCloseTimer();\n    closeTimerRef.current = setTimeout(close, SUCCESS_DURATION_MS);\n  };\n\n  const submit = async () => {\n    if (busy || message.trim().length === 0) return;\n    setStatus(\"sending\");\n    try {\n      await onSubmit?.({ message });\n      setStatus(\"sent\");\n      scheduleSuccessClose();\n    } catch {\n      // Preserve the message so a rejected submission can be retried.\n      setStatus(\"error\");\n    }\n  };\n\n  const left = position === \"bottom-left\";\n  const contentOffset = left ? -MORPH_SLIDE : MORPH_SLIDE;\n  const surfaceTransition = reduce\n    ? { duration: 0 }\n    : {\n        layout: {\n          duration: open ? MORPH_OPEN_DURATION : MORPH_CLOSE_DURATION,\n          ease: open ? MORPH_OPEN_EASE : MORPH_CLOSE_EASE,\n        },\n        borderRadius: {\n          duration: open ? MORPH_OPEN_DURATION : MORPH_CLOSE_DURATION,\n          ease: open ? MORPH_OPEN_EASE : MORPH_CLOSE_EASE,\n        },\n      };\n  const contentTransition = reduce\n    ? { duration: 0 }\n    : {\n        opacity: {\n          duration: MORPH_FADE_DURATION,\n          ease: MORPH_CLOSE_EASE,\n        },\n        x: {\n          duration: MORPH_OPEN_DURATION,\n          ease: MORPH_CLOSE_EASE,\n        },\n        scale: {\n          duration: MORPH_OPEN_DURATION,\n          ease: MORPH_CLOSE_EASE,\n        },\n        filter: {\n          duration: MORPH_FADE_DURATION,\n          ease: MORPH_CLOSE_EASE,\n        },\n        rotate: {\n          duration: MORPH_OPEN_DURATION,\n          ease: MORPH_CLOSE_EASE,\n        },\n      };\n  const viewInitial = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, y: 8, filter: \"blur(4px)\" };\n  const viewAnimate = reduce\n    ? {\n        opacity: 1,\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }\n    : {\n        opacity: 1,\n        y: 0,\n        filter: \"blur(0px)\",\n        transition: { duration: 0.24, ease: EASE_OUT },\n      };\n  const viewExit = reduce\n    ? {\n        opacity: 0,\n        transition: { duration: 0.14, ease: EASE_OUT },\n      }\n    : {\n        opacity: 0,\n        y: -8,\n        filter: \"blur(4px)\",\n        transition: { duration: 0.16, ease: EASE_OUT },\n      };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"pointer-events-none absolute bottom-4 z-30\",\n        left ? \"left-4\" : \"right-4\",\n        className,\n      )}\n    >\n      {/* One persistent shell grows out of the corner trigger. The shell and\n          its contents use separate timing so the surface leads the reveal. */}\n      <motion.div\n        layout\n        animate={{ borderRadius: open ? 20 : 40 }}\n        transition={surfaceTransition}\n        style={{ transformOrigin: left ? \"bottom left\" : \"bottom right\" }}\n        className={cn(\n          \"pointer-events-auto absolute bottom-0 overflow-hidden border border-border bg-background text-foreground shadow-lg\",\n          open ? \"w-[min(86vw,320px)] p-2\" : \"h-12 w-12 p-0\",\n          left ? \"left-0\" : \"right-0\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {open ? (\n            <motion.div\n              key=\"panel\"\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      x: contentOffset,\n                      scale: MORPH_SCALE,\n                      filter: MORPH_BLUR,\n                    }\n              }\n              animate={{ opacity: 1, x: 0, scale: 1, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      x: contentOffset,\n                      scale: MORPH_SCALE,\n                      filter: MORPH_BLUR,\n                    }\n              }\n              transition={contentTransition}\n            >\n              <motion.div layout=\"position\">\n                <AnimatePresence mode=\"popLayout\" initial={false} propagate>\n                  {status === \"sent\" ? (\n                    <motion.div\n                      key=\"sent\"\n                      initial={viewInitial}\n                      animate={viewAnimate}\n                      exit={viewExit}\n                    >\n                      <div className=\"flex flex-col items-center justify-center gap-1.5 rounded-[20px] bg-border/60 px-4 py-6 text-center\">\n                        <div className=\"relative mb-1 flex h-12 w-12 items-center justify-center\">\n                          {reduce\n                            ? null\n                            : SPRINKLES.map((s, i) => (\n                                <motion.span\n                                  key={`${s.x}-${s.y}`}\n                                  initial={{ opacity: 0, scale: 0, x: 0, y: 0 }}\n                                  animate={{\n                                    opacity: [0, 1, 0],\n                                    scale: [0, 1, 0.4],\n                                    x: s.x,\n                                    y: s.y,\n                                  }}\n                                  transition={{\n                                    duration: 0.6,\n                                    delay: 0.18 + i * 0.02,\n                                    ease: \"easeOut\",\n                                  }}\n                                  style={{ backgroundColor: s.color }}\n                                  className=\"absolute h-1.5 w-1.5 rounded-full\"\n                                />\n                              ))}\n                          <motion.div\n                            initial={reduce ? { scale: 1 } : { scale: 0 }}\n                            animate={{ scale: 1 }}\n                            transition={{\n                              type: \"spring\",\n                              stiffness: 500,\n                              damping: 22,\n                              delay: 0.04,\n                            }}\n                            className=\"flex h-12 w-12 items-center justify-center rounded-full bg-(--color-success)\"\n                          >\n                            <motion.svg\n                              viewBox=\"0 0 24 24\"\n                              fill=\"none\"\n                              className=\"h-5 w-5 text-white\"\n                            >\n                              <motion.path\n                                d=\"M5 12.5l4.5 4.5L19 7.5\"\n                                stroke=\"currentColor\"\n                                strokeWidth={2.5}\n                                strokeLinecap=\"round\"\n                                strokeLinejoin=\"round\"\n                                initial={\n                                  reduce ? { pathLength: 1 } : { pathLength: 0 }\n                                }\n                                animate={{ pathLength: 1 }}\n                                transition={{\n                                  duration: 0.35,\n                                  ease: \"easeOut\",\n                                  delay: 0.15,\n                                }}\n                              />\n                            </motion.svg>\n                          </motion.div>\n                        </div>\n                        <h3 className=\"text-sm font-semibold text-foreground\">\n                          Thanks!\n                        </h3>\n                        <p className=\"text-xs leading-relaxed text-muted-foreground\">\n                          Your feedback helps us build something better.\n                        </p>\n                      </div>\n                    </motion.div>\n                  ) : status === \"error\" ? (\n                    <motion.div\n                      key=\"error\"\n                      initial={viewInitial}\n                      animate={viewAnimate}\n                      exit={viewExit}\n                    >\n                      <div\n                        role=\"alert\"\n                        className=\"rounded-[20px] bg-border/60 px-4 py-5 text-center\"\n                      >\n                        <div className=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive\">\n                          <AlertCircle className=\"h-5 w-5\" />\n                        </div>\n                        <h3 className=\"mt-3 text-sm font-semibold text-foreground\">\n                          Something went wrong\n                        </h3>\n                        <p className=\"mt-1 text-xs leading-relaxed text-muted-foreground\">\n                          We couldn&apos;t send your feedback. Please try again.\n                        </p>\n                        <Button size=\"sm\" onClick={submit} className=\"mt-4\">\n                          Try again\n                        </Button>\n                      </div>\n                    </motion.div>\n                  ) : (\n                    <motion.div\n                      key=\"form\"\n                      initial={viewInitial}\n                      animate={viewAnimate}\n                      exit={viewExit}\n                    >\n                      <div className=\"min-h-[150px] rounded-[20px] bg-border/60 px-4 py-3.5\">\n                        <div className=\"flex items-start justify-between gap-3\">\n                          <h3 className=\"text-sm font-semibold text-foreground\">\n                            {title}\n                          </h3>\n                          <button\n                            type=\"button\"\n                            onClick={close}\n                            aria-label=\"Close\"\n                            className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-foreground/[0.07] text-muted-foreground transition-colors hover:text-foreground\"\n                          >\n                            <X className=\"h-3 w-3\" />\n                          </button>\n                        </div>\n                        <textarea\n                          ref={textareaRef}\n                          value={message}\n                          onChange={(e) => setMessage(e.target.value)}\n                          placeholder={placeholder}\n                          disabled={busy}\n                          rows={3}\n                          className=\"mt-2 w-full resize-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground/60 md:text-sm\"\n                        />\n                      </div>\n\n                      <div className=\"flex items-center gap-2 px-1 pt-2 pb-1\">\n                        <Button\n                          variant=\"secondary\"\n                          size=\"md\"\n                          onClick={close}\n                          disabled={busy}\n                          className=\"flex-1 bg-border text-muted-foreground hover:text-foreground\"\n                        >\n                          Cancel\n                        </Button>\n                        <StatefulButton\n                          state={busy ? \"loading\" : \"idle\"}\n                          loadingText=\"Sending\"\n                          size=\"md\"\n                          onClick={submit}\n                          disabled={busy || message.trim().length === 0}\n                          className=\"flex-1 bg-foreground text-background hover:bg-foreground/90\"\n                        >\n                          Submit\n                        </StatefulButton>\n                      </div>\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </motion.div>\n            </motion.div>\n          ) : (\n            <motion.button\n              key=\"trigger\"\n              type=\"button\"\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      x: -contentOffset,\n                      filter: MORPH_BLUR,\n                    }\n              }\n              animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      x: -contentOffset,\n                      filter: MORPH_BLUR,\n                    }\n              }\n              transition={contentTransition}\n              onClick={() => {\n                clearCloseTimer();\n                setStatus(\"open\");\n              }}\n              aria-label={title}\n              aria-haspopup=\"dialog\"\n              whileTap={reduce ? undefined : { scale: 0.92 }}\n              className={cn(\n                \"absolute bottom-0 flex h-12 w-12 items-center justify-center bg-transparent text-foreground\",\n                left ? \"left-0\" : \"right-0\",\n              )}\n            >\n              <motion.span\n                initial={\n                  reduce ? false : { rotate: 45, scale: MORPH_SCALE }\n                }\n                animate={{ rotate: 0, scale: 1 }}\n                exit={{ rotate: 45, scale: MORPH_SCALE }}\n                transition={contentTransition}\n                className=\"grid h-5 w-5 shrink-0 place-items-center [&>svg]:h-full [&>svg]:w-full\"\n              >\n                {icon ?? <MessageSquare className=\"h-5 w-5\" />}\n              </motion.span>\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export { Button } from \"./base\";\nexport type { ButtonProps, ButtonVariant, ButtonSize } from \"./base\";\n\nexport { StatefulButton } from \"./stateful\";\nexport type { StatefulButtonProps, ButtonState } from \"./stateful\";\n\nexport { MagneticButton } from \"./magnetic\";\nexport type { MagneticButtonProps } from \"./magnetic\";\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id: nextId.current++,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"registry:component","target":"@components/motion/button/magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"registry:component","target":"@components/motion/button/stateful.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Width is set instantly from the measurer; the parent's single `layout`\n  // animation smooths the resize (text + icons together) so nothing competes.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}