{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wallet-card","type":"registry:block","title":"Wallet Card","description":"Wallet overview card with an account switcher and search that morph open from their triggers, a cascading balance with a live change pill and privacy toggle, copy-address, and Send / Deposit / Swap / Buy actions.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/wallet-card/index.tsx","type":"registry:component","target":"@components/motion/wallet-card/index.tsx","content":"\"use client\";\n// beui.dev/components/blocks/wallet-card\n\nimport { Bell, Eye, EyeOff } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { ActionSwapText } from \"@/components/motion/action-swap\";\nimport { Button } from \"@/components/motion/button\";\nimport { cn } from \"@/lib/utils\";\nimport { AccountSwitcher } from \"./account-switcher\";\nimport { WalletActions } from \"./actions\";\nimport { BalanceDelta } from \"./balance-delta\";\nimport { SearchBar } from \"./search-bar\";\nimport type { WalletCardProps } from \"./types\";\n\nexport type { WalletAccount, WalletCardProps } from \"./types\";\n\n/**\n * Composed wallet overview card: an account switcher whose trigger morphs open\n * into a full-width panel, a search icon that morphs into a search bar, a\n * rolling balance with a transient change indicator, and Send / Deposit\n * actions. Actions and search are plain callbacks — the resulting flow is left\n * to the consumer.\n */\nexport function WalletCard({\n  accounts,\n  accountId,\n  defaultAccountId,\n  onAccountChange,\n  balance,\n  balancePrefix = \"$\",\n  defaultChange,\n  defaultBalanceHidden = false,\n  onSend,\n  onDeposit,\n  onSwap,\n  onBuy,\n  searchPlaceholder,\n  searchRecent,\n  onSearchChange,\n  onSearchSubmit,\n  hasNotifications = false,\n  onNotifications,\n  className,\n}: WalletCardProps) {\n  const accountControlled = accountId !== undefined;\n  const [internalAccountId, setInternalAccountId] = useState(\n    defaultAccountId ?? accounts[0]?.id,\n  );\n  const [balanceHidden, setBalanceHidden] = useState(defaultBalanceHidden);\n\n  const shownBalance = `${balancePrefix}${balance.toLocaleString(undefined, {\n    minimumFractionDigits: 2,\n    maximumFractionDigits: 2,\n  })}`;\n  const maskedBalance = \"*\".repeat(7);\n  const activeAccountId = accountControlled ? accountId : internalAccountId;\n  const activeAccount =\n    accounts.find((a) => a.id === activeAccountId) ?? accounts[0];\n\n  const handleAccountChange = (id: string) => {\n    if (!accountControlled) setInternalAccountId(id);\n    onAccountChange?.(id);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative w-full max-w-xs overflow-hidden rounded-4xl border border-border p-6\",\n        className,\n      )}\n    >\n      {/* relative anchor so the switcher + search panels span the whole row */}\n      <div className=\"relative flex items-center justify-between gap-2\">\n        <AccountSwitcher\n          accounts={accounts}\n          activeAccount={activeAccount}\n          onSelect={handleAccountChange}\n        />\n\n        <div className=\"flex shrink-0 items-center gap-1\">\n          <SearchBar\n            placeholder={searchPlaceholder}\n            recent={searchRecent}\n            onChange={onSearchChange}\n            onSubmit={onSearchSubmit}\n          />\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={onNotifications}\n            aria-label=\"Notifications\"\n            className=\"relative\"\n          >\n            <Bell className=\"h-4 w-4\" />\n            {hasNotifications ? (\n              <span className=\"absolute top-1.5 right-1.5 flex h-2 w-2\">\n                <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60\" />\n                <span className=\"relative inline-flex h-2 w-2 rounded-full bg-primary\" />\n              </span>\n            ) : null}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"mt-8 flex flex-col items-center text-center\">\n        <div className=\"flex items-center gap-1.5\">\n          <p className=\"text-xs text-muted-foreground\">Balance</p>\n          <button\n            type=\"button\"\n            onClick={() => setBalanceHidden((h) => !h)}\n            aria-label={balanceHidden ? \"Show balance\" : \"Hide balance\"}\n            aria-pressed={balanceHidden}\n            className=\"text-muted-foreground outline-none transition-colors hover:text-foreground\"\n          >\n            {balanceHidden ? (\n              <EyeOff className=\"h-3.5 w-3.5\" />\n            ) : (\n              <Eye className=\"h-3.5 w-3.5\" />\n            )}\n          </button>\n        </div>\n        {/* One ActionSwapText swaps the number and the asterisk mask with a\n            per-letter cascade — same baseline, no overlap or layout shift. */}\n        <ActionSwapText\n          value={balanceHidden ? \"hidden\" : shownBalance}\n          animation=\"cascade\"\n          className=\"text-3xl font-semibold text-foreground\"\n        >\n          {balanceHidden ? maskedBalance : shownBalance}\n        </ActionSwapText>\n        {balanceHidden ? (\n          <div className=\"mt-2 flex h-7 items-center justify-center\">\n            <span className=\"translate-y-[3px] text-sm font-semibold text-muted-foreground leading-none tracking-[0.3em]\">\n              *****\n            </span>\n          </div>\n        ) : (\n          <BalanceDelta balance={balance} initialChange={defaultChange} />\n        )}\n      </div>\n\n      <div className=\"mt-8\">\n        <WalletActions\n          onSend={onSend}\n          onDeposit={onDeposit}\n          onSwap={onSwap}\n          onBuy={onBuy}\n        />\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/wallet-card/account-switcher.tsx","type":"registry:component","target":"@components/motion/wallet-card/account-switcher.tsx","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { AccountAvatar } from \"./account-avatar\";\nimport { HEAD, ITEM, LIST, MORPH } from \"./constants\";\nimport { CopyButton } from \"./copy-button\";\nimport type { WalletAccount } from \"./types\";\nimport { useDismiss } from \"./use-dismiss\";\nimport { truncateAddress } from \"./utils\";\n\n/**\n * Account switcher whose trigger morphs into a panel that grows rightward to\n * full width (covering the header icons) and downward at the same time, via a\n * shared layoutId. Self-contained — not the generic MorphSelect.\n */\nexport function AccountSwitcher({\n  accounts,\n  activeAccount,\n  onSelect,\n}: {\n  accounts: WalletAccount[];\n  activeAccount: WalletAccount | undefined;\n  onSelect: (id: string) => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const layoutId = `${useId()}-account`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [open, setOpen] = useState(false);\n  // Hover only arms after the morph settles — otherwise the panel expands under\n  // the cursor and flashes a phantom hover bg on whatever item it lands on.\n  const [armed, setArmed] = useState(false);\n  const close = useCallback(() => setOpen(false), []);\n\n  useDismiss(open, close, rootRef);\n\n  useEffect(() => {\n    if (!open) {\n      setArmed(false);\n      return;\n    }\n    const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 280);\n    return () => window.clearTimeout(t);\n  }, [open, reduce]);\n\n  const morph = reduce ? { duration: 0 } : MORPH;\n\n  return (\n    // static (not relative) so the absolute trigger + panel anchor to the\n    // header row and can span its full width, covering the icons.\n    <div ref={rootRef} className=\"min-w-0\">\n      {/* in-flow sizer: reserves the widest possible trigger footprint (every\n          account name stacked in one grid cell) so the header row width never\n          changes — neither when the trigger leaves the flow nor when a shorter\n          account name is selected. */}\n      <div aria-hidden className={cn(HEAD, \"pointer-events-none opacity-0\")}>\n        <AccountAvatar account={activeAccount ?? accounts[0]} />\n        <span className=\"grid\">\n          {accounts.map((account) => (\n            <span\n              key={account.id}\n              className=\"col-start-1 row-start-1 whitespace-nowrap text-sm font-medium\"\n            >\n              {account.name}\n            </span>\n          ))}\n        </span>\n        <ChevronDown className=\"h-4 w-4\" />\n      </div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? null : (\n          <motion.button\n            key=\"trigger\"\n            layoutId={layoutId}\n            type=\"button\"\n            aria-haspopup=\"listbox\"\n            aria-expanded={false}\n            onClick={() => setOpen(true)}\n            transition={morph}\n            style={{ borderRadius: 16 }}\n            className={cn(\n              HEAD,\n              // shifted left by the padding so the avatar sits flush with the\n              // card content edge (aligned with the balance + buttons below).\n              \"absolute top-0 -left-2 z-20 max-w-full outline-none transition-colors hover:bg-muted/60\",\n            )}\n          >\n            {activeAccount ? (\n              <>\n                <AccountAvatar account={activeAccount} />\n                <motion.span\n                  layout=\"position\"\n                  className=\"truncate text-sm font-medium text-foreground\"\n                >\n                  {activeAccount.name}\n                </motion.span>\n                <motion.span layout=\"position\" className=\"text-muted-foreground\">\n                  <ChevronDown className=\"h-4 w-4\" />\n                </motion.span>\n              </>\n            ) : null}\n          </motion.button>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? (\n          <motion.div\n            key=\"panel\"\n            layoutId={layoutId}\n            role=\"listbox\"\n            transition={morph}\n            style={{ borderRadius: 16 }}\n            className=\"absolute top-0 -right-2 -left-2 z-30 overflow-hidden border border-border/30 bg-background backdrop-blur-md\"\n          >\n            <button\n              type=\"button\"\n              onClick={() => setOpen(false)}\n              className={cn(HEAD, \"w-full outline-none\")}\n            >\n              {activeAccount ? <AccountAvatar account={activeAccount} /> : null}\n              <motion.span\n                layout=\"position\"\n                className=\"min-w-0 flex-1 truncate text-sm font-medium text-foreground\"\n              >\n                {activeAccount?.name ?? \"Select account\"}\n              </motion.span>\n              <motion.span\n                layout=\"position\"\n                animate={{ rotate: 180 }}\n                transition={morph}\n                className=\"text-muted-foreground\"\n              >\n                <ChevronDown className=\"h-4 w-4\" />\n              </motion.span>\n            </button>\n\n            <motion.ul\n              initial=\"hidden\"\n              animate=\"show\"\n              variants={reduce ? undefined : LIST}\n              className={cn(\n                \"max-h-64 overflow-y-auto p-1.5\",\n                armed ? \"\" : \"pointer-events-none\",\n              )}\n            >\n              {accounts.map((account) => {\n                const selected = account.id === activeAccount?.id;\n                return (\n                  <motion.li\n                    key={account.id}\n                    variants={reduce ? undefined : ITEM}\n                    className={cn(\n                      \"flex items-center rounded-xl pr-1 text-sm transition-colors\",\n                      selected\n                        ? \"bg-muted text-foreground\"\n                        : cn(\n                            \"text-muted-foreground\",\n                            armed && \"hover:bg-muted hover:text-foreground\",\n                          ),\n                    )}\n                  >\n                    <button\n                      type=\"button\"\n                      role=\"option\"\n                      aria-selected={selected}\n                      onClick={() => {\n                        onSelect(account.id);\n                        setOpen(false);\n                      }}\n                      className=\"flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-2 text-left outline-none\"\n                    >\n                      <AccountAvatar account={account} />\n                      <span className=\"flex min-w-0 flex-1 flex-col\">\n                        <span className=\"truncate font-medium\">\n                          {account.name}\n                        </span>\n                        <span className=\"truncate text-xs text-muted-foreground\">\n                          {truncateAddress(account.address)}\n                        </span>\n                      </span>\n                      {selected ? (\n                        <Check className=\"h-4 w-4 shrink-0 text-foreground\" />\n                      ) : null}\n                    </button>\n                    <CopyButton value={account.address} />\n                  </motion.li>\n                );\n              })}\n            </motion.ul>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/wallet-card/actions.tsx","type":"registry:component","target":"@components/motion/wallet-card/actions.tsx","content":"\"use client\";\n\nimport { ArrowDownToLine, ArrowUp, CreditCard, Repeat } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentType } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\n\ntype WalletAction = {\n  key: string;\n  label: string;\n  icon: ComponentType<{ className?: string }>;\n  onClick?: () => void;\n};\n\n/**\n * Row of primary wallet actions rendered icon-over-label, with a spring press.\n */\nexport function WalletActions({\n  onSend,\n  onDeposit,\n  onSwap,\n  onBuy,\n}: {\n  onSend?: () => void;\n  onDeposit?: () => void;\n  onSwap?: () => void;\n  onBuy?: () => void;\n}) {\n  const reduce = useReducedMotion();\n\n  const actions: WalletAction[] = [\n    { key: \"send\", label: \"Send\", icon: ArrowUp, onClick: onSend },\n    { key: \"deposit\", label: \"Deposit\", icon: ArrowDownToLine, onClick: onDeposit },\n    { key: \"swap\", label: \"Swap\", icon: Repeat, onClick: onSwap },\n    { key: \"buy\", label: \"Buy\", icon: CreditCard, onClick: onBuy },\n  ];\n\n  return (\n    <div className=\"flex items-start justify-between gap-2\">\n      {actions.map(({ key, label, icon: Icon, onClick }) => (\n        <motion.button\n          key={key}\n          type=\"button\"\n          onClick={onClick}\n          whileTap={reduce ? undefined : { scale: 0.94 }}\n          transition={SPRING_PRESS}\n          className=\"flex flex-1 flex-col items-center gap-2 outline-none\"\n        >\n          <span className=\"flex h-12 w-12 items-center justify-center rounded-full bg-muted text-foreground\">\n            <Icon className=\"h-5 w-5\" />\n          </span>\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            {label}\n          </span>\n        </motion.button>\n      ))}\n    </div>\n  );\n}\n"},{"path":"components/motion/wallet-card/balance-delta.tsx","type":"registry:component","target":"@components/motion/wallet-card/balance-delta.tsx","content":"\"use client\";\n\nimport { TrendingDown, TrendingUp } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A transient change indicator for the balance: a tinted pill with a trend\n * arrow that pops in whenever the balance moves and persists until it moves\n * again.\n */\nexport function BalanceDelta({\n  balance,\n  initialChange,\n}: {\n  balance: number;\n  initialChange?: number;\n}) {\n  const reduce = useReducedMotion();\n  const prevRef = useRef(balance);\n  const [delta, setDelta] = useState<{ id: number; amount: number } | null>(\n    initialChange ? { id: 0, amount: initialChange } : null,\n  );\n\n  // Persist the last change until the balance moves again — don't auto-hide.\n  useEffect(() => {\n    const diff = balance - prevRef.current;\n    prevRef.current = balance;\n    if (diff === 0) return;\n    setDelta({ id: Date.now(), amount: diff });\n  }, [balance]);\n\n  const up = (delta?.amount ?? 0) > 0;\n\n  return (\n    <div className=\"mt-2 flex h-7 items-center justify-center\">\n      <AnimatePresence mode=\"wait\">\n        {delta ? (\n          <motion.span\n            key={delta.id}\n            initial={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.9 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.9 }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n            className={cn(\n              \"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums\",\n              up\n                ? \"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400\"\n                : \"bg-red-500/15 text-red-600 dark:text-red-400\",\n            )}\n          >\n            {up ? (\n              <TrendingUp className=\"h-3.5 w-3.5\" />\n            ) : (\n              <TrendingDown className=\"h-3.5 w-3.5\" />\n            )}\n            {up ? \"+\" : \"-\"}$\n            {Math.abs(delta.amount).toLocaleString(undefined, {\n              minimumFractionDigits: 2,\n              maximumFractionDigits: 2,\n            })}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/wallet-card/search-bar.tsx","type":"registry:component","target":"@components/motion/wallet-card/search-bar.tsx","content":"\"use client\";\n\nimport { History, Search } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { ITEM, LIST, MORPH } from \"./constants\";\nimport { useDismiss } from \"./use-dismiss\";\n\n/**\n * Search icon that morphs into a full-width search bar via a shared layoutId,\n * growing leftward across the header row. The recent-searches results render as\n * a SEPARATE dropdown below the bar — not part of the morphing element — so\n * filtering as you type resizes only the dropdown and never re-fires the morph\n * (which would scale-distort the input text). The in-flow slot keeps its width\n * whether open or closed so the header row (and card) never shifts.\n */\nexport function SearchBar({\n  placeholder = \"Search\",\n  recent = [],\n  onChange,\n  onSubmit,\n}: {\n  placeholder?: string;\n  recent?: string[];\n  onChange?: (value: string) => void;\n  onSubmit?: (value: string) => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const layoutId = `${useId()}-search`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [open, setOpen] = useState(false);\n  const [value, setValue] = useState(\"\");\n  // Hover arms after the dropdown reveals so it doesn't flash a phantom hover.\n  const [armed, setArmed] = useState(false);\n  const close = useCallback(() => setOpen(false), []);\n\n  useDismiss(open, close, rootRef);\n\n  useEffect(() => {\n    if (open) inputRef.current?.focus();\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) {\n      setArmed(false);\n      return;\n    }\n    const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 260);\n    return () => window.clearTimeout(t);\n  }, [open, reduce]);\n\n  // The box keeps the bouncy morph (same feel as the account switcher)...\n  const morph: Transition = reduce ? { duration: 0 } : MORPH;\n  // ...but the leading icon + input travel across the row, so their own layout\n  // uses a critically-damped spring — they glide to place without inheriting the\n  // box's overshoot (which read as the icon jittering right-then-left).\n  const glide: Transition = reduce\n    ? { duration: 0 }\n    : { type: \"spring\", duration: 0.5, bounce: 0 };\n\n  const query = value.trim().toLowerCase();\n  const filtered = query\n    ? recent.filter((r) => r.toLowerCase().includes(query))\n    : recent;\n\n  const submit = (next: string) => {\n    onSubmit?.(next);\n    setOpen(false);\n  };\n\n  return (\n    // static so the open bar + dropdown anchor to the header row (spanning its\n    // width), while the slot below reserves the icon's footprint.\n    <div ref={rootRef} className=\"shrink-0\">\n      {/* reserve the icon's width while open (the icon has left the flow) */}\n      {open ? <div aria-hidden className=\"h-8 w-8\" /> : null}\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? null : (\n          <motion.button\n            key=\"icon\"\n            layoutId={layoutId}\n            type=\"button\"\n            aria-label=\"Search\"\n            onClick={() => setOpen(true)}\n            transition={morph}\n            style={{ borderRadius: 12 }}\n            className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground\"\n          >\n            <Search className=\"h-4 w-4\" />\n          </motion.button>\n        )}\n      </AnimatePresence>\n\n      {/* one connected panel: input + results grow out of the trigger. The text\n          nodes use layout=\"position\" so when the panel resizes (filtering) they\n          reposition without scaling — no re-morph distortion, still connected. */}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? (\n          <motion.div\n            key=\"panel\"\n            layoutId={layoutId}\n            transition={morph}\n            style={{ borderRadius: 16 }}\n            className=\"absolute top-0 -right-2 -left-2 z-40 overflow-hidden border border-border/30 bg-background backdrop-blur-md\"\n          >\n            <div className=\"flex h-9 items-center gap-2 px-3\">\n              <motion.span\n                layout=\"position\"\n                transition={glide}\n                className=\"shrink-0\"\n              >\n                <Search className=\"h-4 w-4 text-muted-foreground\" />\n              </motion.span>\n              <motion.input\n                ref={inputRef}\n                layout=\"position\"\n                initial={reduce ? false : { opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{\n                  opacity: { duration: 0.15, delay: 0.12, ease: EASE_OUT },\n                  layout: glide,\n                }}\n                value={value}\n                onChange={(e) => {\n                  setValue(e.target.value);\n                  onChange?.(e.target.value);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" && value.trim()) submit(value.trim());\n                  if (e.key === \"Escape\") close();\n                }}\n                placeholder={placeholder}\n                className=\"min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n              />\n            </div>\n\n            <div className=\"h-px bg-border/40\" />\n\n            {filtered.length > 0 ? (\n              <motion.ul\n                initial=\"hidden\"\n                animate=\"show\"\n                variants={reduce ? undefined : LIST}\n                className={cn(\n                  \"max-h-56 overflow-y-auto p-1.5\",\n                  armed ? \"\" : \"pointer-events-none\",\n                )}\n              >\n                {filtered.map((term) => (\n                  <motion.li\n                    key={term}\n                    layout=\"position\"\n                    variants={reduce ? undefined : ITEM}\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        setValue(term);\n                        onChange?.(term);\n                        submit(term);\n                      }}\n                      className={cn(\n                        \"flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm text-muted-foreground outline-none transition-colors\",\n                        armed && \"hover:bg-muted hover:text-foreground\",\n                      )}\n                    >\n                      <History className=\"h-4 w-4 shrink-0\" />\n                      <span className=\"min-w-0 flex-1 truncate\">{term}</span>\n                    </button>\n                  </motion.li>\n                ))}\n              </motion.ul>\n            ) : (\n              <motion.div\n                layout=\"position\"\n                className=\"flex flex-col items-center gap-1 px-4 py-8 text-center\"\n              >\n                <Search className=\"h-5 w-5 text-muted-foreground/60\" />\n                <p className=\"text-sm text-muted-foreground\">\n                  {query ? \"No matches\" : \"No recent searches\"}\n                </p>\n              </motion.div>\n            )}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/wallet-card/types.ts","type":"registry:component","target":"@components/motion/wallet-card/types.ts","content":"import type { ReactNode } from \"react\";\n\nexport type WalletAccount = {\n  id: string;\n  name: string;\n  address: string;\n  avatar?: ReactNode;\n};\n\nexport interface WalletCardProps {\n  accounts: WalletAccount[];\n  accountId?: string;\n  defaultAccountId?: string;\n  onAccountChange?: (id: string) => void;\n  balance: number;\n  balancePrefix?: string;\n  /** Initial balance change shown in the pill before any live change. */\n  defaultChange?: number;\n  /** Start with the balance hidden behind dots. */\n  defaultBalanceHidden?: boolean;\n  onSend?: () => void;\n  onDeposit?: () => void;\n  onSwap?: () => void;\n  onBuy?: () => void;\n  searchPlaceholder?: string;\n  /** Recent searches shown in the expanded search panel. */\n  searchRecent?: string[];\n  onSearchChange?: (value: string) => void;\n  onSearchSubmit?: (value: string) => void;\n  /** Show an unread pulse on the notifications bell. */\n  hasNotifications?: boolean;\n  onNotifications?: () => void;\n  className?: string;\n}\n"},{"path":"components/motion/action-swap.tsx","type":"registry:component","target":"@components/motion/action-swap.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\nconst ROLL_BLUR = \"blur(6px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\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 TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"115%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-115%\",\n      filter: ROLL_BLUR,\n      transition: { duration: 0.18, ease: \"easeInOut\" },\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 16, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -16,\n      filter: ROLL_BLUR,\n      transition: { duration: 0.18, ease: \"easeInOut\" },\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, 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  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));\n  });\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\", className)}\n      style={{\n        width,\n        transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,\n      }}\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\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, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * 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={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\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/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/wallet-card/account-avatar.tsx","type":"registry:component","target":"@components/motion/wallet-card/account-avatar.tsx","content":"import { cn } from \"@/lib/utils\";\nimport type { WalletAccount } from \"./types\";\nimport { diceBearGlassUrl } from \"./utils\";\n\nexport function AccountAvatar({\n  account,\n  className,\n}: {\n  account: WalletAccount;\n  className?: string;\n}) {\n  if (account.avatar) return <>{account.avatar}</>;\n  return (\n    // biome-ignore lint/performance/noImgElement: remote DiceBear SVG, no next/image benefit\n    <img\n      src={diceBearGlassUrl(account.id || account.address)}\n      alt={account.name}\n      className={cn(\"h-7 w-7 shrink-0 rounded-full bg-muted\", className)}\n    />\n  );\n}\n"},{"path":"components/motion/wallet-card/constants.ts","type":"registry:component","target":"@components/motion/wallet-card/constants.ts","content":"import type { Transition, Variants } from \"motion/react\";\n\n// Trigger box grows into the full-width panel and back — one shared surface.\nexport const MORPH: Transition = { type: \"spring\", duration: 0.5, bounce: 0.22 };\n\nexport const LIST: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.12 } },\n};\n\nexport const ITEM: Variants = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\n// Shared padding for the account trigger + panel header so the avatar/name stay\n// put as the box morphs — the panel reads as the trigger itself growing open.\nexport const HEAD = \"flex items-center gap-2 px-2 py-1.5 text-left\";\n"},{"path":"components/motion/wallet-card/copy-button.tsx","type":"registry:component","target":"@components/motion/wallet-card/copy-button.tsx","content":"\"use client\";\n\nimport { Check, Copy } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { ActionSwapIcon } from \"@/components/motion/action-swap\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Copies `value` to the clipboard and swaps the copy icon for a check via the\n * library's ActionSwapIcon. Stops click propagation so it can sit inside a\n * selectable row.\n */\nexport function CopyButton({\n  value,\n  className,\n}: {\n  value: string;\n  className?: string;\n}) {\n  const [copied, setCopied] = useState(false);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(value);\n    } catch {\n      // clipboard may be unavailable (insecure context) — swap anyway\n    }\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1400);\n  };\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={copied ? \"Copied\" : \"Copy address\"}\n      onClick={(e) => {\n        e.stopPropagation();\n        copy();\n      }}\n      className={cn(\n        \"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground\",\n        className,\n      )}\n    >\n      <ActionSwapIcon\n        value={copied ? \"check\" : \"copy\"}\n        animation=\"cascade\"\n        className=\"h-3.5 w-3.5\"\n      >\n        {copied ? (\n          <Check className=\"h-3.5 w-3.5 text-emerald-500\" />\n        ) : (\n          <Copy className=\"h-3.5 w-3.5\" />\n        )}\n      </ActionSwapIcon>\n    </button>\n  );\n}\n"},{"path":"components/motion/wallet-card/use-dismiss.ts","type":"registry:component","target":"@components/motion/wallet-card/use-dismiss.ts","content":"import { type RefObject, useEffect } from \"react\";\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. `onDismiss`\n * must be stable (wrap in useCallback) so the listeners aren't re-bound every\n * render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null>,\n) {\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (e: PointerEvent) => {\n      if (ref.current && !ref.current.contains(e.target as Node)) onDismiss();\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, onDismiss, ref]);\n}\n"},{"path":"components/motion/wallet-card/utils.ts","type":"registry:component","target":"@components/motion/wallet-card/utils.ts","content":"export function diceBearGlassUrl(seed: string) {\n  return `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(seed)}`;\n}\n\nexport function truncateAddress(address: string) {\n  return address.length > 12\n    ? `${address.slice(0, 6)}…${address.slice(-4)}`\n    : address;\n}\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":"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"}]}