{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dialog","type":"registry:component","title":"Dialog","description":"Composable modal Dialog and AlertDialog that portal to the body over a blurred backdrop, scale the panel up and exit faster, trap focus, close on Escape, lock page scroll without layout shift and hand focus back to the trigger, with controlled/uncontrolled open, a bottom-sheet layout below sm, an alertdialog that ignores outside clicks and focuses Cancel first, and an optional type-to-confirm field that shakes when Delete is pressed too early.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/dialog.tsx","type":"registry:component","target":"@components/motion/dialog.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/dialog\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ComponentPropsWithoutRef,\n  type MouseEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  cloneElement,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Button, type ButtonProps } from \"@/components/motion/button\";\nimport { Input, type InputProps } from \"@/components/motion/input\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                               Shared plumbing                              */\n/* -------------------------------------------------------------------------- */\n\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled]):not([type='hidden'])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"[contenteditable='true']\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\nfunction getFocusable(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n    (el) => el.tabIndex >= 0 && !el.closest(\"[inert]\") && el.getClientRects().length > 0,\n  );\n}\n\n// Open dialogs, innermost last. Only the top one answers Escape and Tab, so an\n// AlertDialog opened from inside a Dialog doesn't close both at once.\nconst dialogStack: string[] = [];\n\n// Reference-counted body scroll lock. The scrollbar's width is put back as\n// padding so the page behind doesn't jump sideways when the bar disappears.\nlet lockCount = 0;\nlet savedBodyStyle: { overflow: string; paddingRight: string } | null = null;\n\nfunction lockScroll() {\n  lockCount += 1;\n  if (lockCount > 1) return;\n  const body = document.body;\n  const scrollbar = window.innerWidth - document.documentElement.clientWidth;\n  savedBodyStyle = {\n    overflow: body.style.overflow,\n    paddingRight: body.style.paddingRight,\n  };\n  if (scrollbar > 0) {\n    const current = Number.parseFloat(getComputedStyle(body).paddingRight) || 0;\n    body.style.paddingRight = `${current + scrollbar}px`;\n  }\n  body.style.overflow = \"hidden\";\n}\n\nfunction unlockScroll() {\n  lockCount = Math.max(0, lockCount - 1);\n  if (lockCount > 0 || !savedBodyStyle) return;\n  document.body.style.overflow = savedBodyStyle.overflow;\n  document.body.style.paddingRight = savedBodyStyle.paddingRight;\n  savedBodyStyle = null;\n}\n\nconst SM_QUERY = \"(max-width: 639.98px)\";\n\nfunction subscribeSmall(callback: () => void) {\n  const mql = window.matchMedia(SM_QUERY);\n  mql.addEventListener(\"change\", callback);\n  return () => mql.removeEventListener(\"change\", callback);\n}\n\n/** True below Tailwind's `sm` breakpoint. Always false on the server. */\nfunction useBelowSm() {\n  return useSyncExternalStore(\n    subscribeSmall,\n    () => window.matchMedia(SM_QUERY).matches,\n    () => false,\n  );\n}\n\nconst SHAKE_X = [0, -7, 7, -5, 5, -2, 0];\n\n/* -------------------------------------------------------------------------- */\n/*                                   Context                                  */\n/* -------------------------------------------------------------------------- */\n\ntype DialogContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  alert: boolean;\n  contentId: string;\n  titleId: string;\n  descriptionId: string;\n  hasDescription: boolean;\n  setHasDescription: (value: boolean) => void;\n  /** Type-to-confirm (AlertDialog only). */\n  confirmText?: string;\n  confirmValue: string;\n  setConfirmValue: (value: string) => void;\n  confirmValid: boolean;\n  confirmAttempted: boolean;\n  setConfirmAttempted: (value: boolean) => void;\n  confirmFieldRef: RefObject<HTMLDivElement | null>;\n  confirmInputRef: RefObject<HTMLInputElement | null>;\n};\n\nconst DialogContext = createContext<DialogContextValue | null>(null);\n\nfunction useDialogContext(component: string) {\n  const context = useContext(DialogContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <Dialog> or <AlertDialog>`);\n  }\n  return context;\n}\n\nexport interface DialogProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nfunction DialogRoot({\n  children,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  alert,\n  confirmText,\n}: DialogProps & { alert: boolean; confirmText?: string }) {\n  const id = useId();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = openProp !== undefined;\n  const open = controlled ? openProp : internalOpen;\n  const [hasDescription, setHasDescription] = useState(false);\n  const [confirmValue, setConfirmValue] = useState(\"\");\n  const [confirmAttempted, setConfirmAttempted] = useState(false);\n  const confirmFieldRef = useRef<HTMLDivElement>(null);\n  const confirmInputRef = useRef<HTMLInputElement>(null);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  // A fresh confirmation every time the dialog opens.\n  useEffect(() => {\n    if (!open) return;\n    setConfirmValue(\"\");\n    setConfirmAttempted(false);\n  }, [open]);\n\n  const confirmValid =\n    confirmText === undefined || confirmValue.trim() === confirmText;\n\n  const value = useMemo<DialogContextValue>(\n    () => ({\n      open,\n      setOpen,\n      alert,\n      contentId: `${id}-content`,\n      titleId: `${id}-title`,\n      descriptionId: `${id}-description`,\n      hasDescription,\n      setHasDescription,\n      confirmText,\n      confirmValue,\n      setConfirmValue,\n      confirmValid,\n      confirmAttempted,\n      setConfirmAttempted,\n      confirmFieldRef,\n      confirmInputRef,\n    }),\n    [\n      open,\n      setOpen,\n      alert,\n      id,\n      hasDescription,\n      confirmText,\n      confirmValue,\n      confirmValid,\n      confirmAttempted,\n    ],\n  );\n\n  return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;\n}\n\n/**\n * Modal dialog root. Holds open state (controlled via `open`/`onOpenChange`\n * or uncontrolled via `defaultOpen`) for its Trigger, Content and Close parts.\n */\nexport function Dialog(props: DialogProps) {\n  return <DialogRoot {...props} alert={false} />;\n}\n\nexport interface AlertDialogProps extends DialogProps {\n  /**\n   * Require the user to type this exact text (e.g. the project name) into\n   * `AlertDialogConfirmInput` before `AlertDialogAction` goes through.\n   */\n  confirmText?: string;\n}\n\n/**\n * Interrupting confirmation. Renders `role=\"alertdialog\"`, never closes on an\n * outside click, and focuses `AlertDialogCancel` first so a stray Enter can't\n * confirm a destructive action.\n */\nexport function AlertDialog({ confirmText, ...props }: AlertDialogProps) {\n  return <DialogRoot {...props} alert confirmText={confirmText} />;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               Trigger & Close                              */\n/* -------------------------------------------------------------------------- */\n\ntype ClonableChild = ReactElement<Record<string, unknown>>;\n\nfunction composeClick(\n  child: ClonableChild,\n  action: () => void,\n): (event: MouseEvent<HTMLElement>) => void {\n  const childOnClick = child.props.onClick as\n    | ((event: MouseEvent<HTMLElement>) => void)\n    | undefined;\n  return (event) => {\n    childOnClick?.(event);\n    if (!event.defaultPrevented) action();\n  };\n}\n\nexport interface DialogTriggerProps extends ButtonProps {\n  /** Merge behaviour onto the single child element instead of rendering a `Button`. */\n  asChild?: boolean;\n}\n\n/** Opens the dialog. Renders a library `Button`, or wraps its child with `asChild`. */\nexport const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(\n  function DialogTrigger({ asChild, children, onClick, ...rest }, ref) {\n    const context = useDialogContext(\"DialogTrigger\");\n    const aria = {\n      \"aria-haspopup\": \"dialog\" as const,\n      \"aria-expanded\": context.open,\n      \"aria-controls\": context.open ? context.contentId : undefined,\n    };\n\n    if (asChild && isValidElement(children)) {\n      const child = children as ClonableChild;\n      return cloneElement(child, {\n        ...aria,\n        onClick: composeClick(child, () => context.setOpen(true)),\n      });\n    }\n\n    return (\n      <Button\n        ref={ref}\n        {...rest}\n        {...aria}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(true);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\nexport interface DialogCloseProps extends ButtonProps {\n  /** Merge behaviour onto the single child element instead of rendering a `Button`. */\n  asChild?: boolean;\n}\n\n/** Closes the dialog. Renders a library `Button` (outline by default), or wraps its child. */\nexport const DialogClose = forwardRef<HTMLButtonElement, DialogCloseProps>(\n  function DialogClose(\n    { asChild, children, onClick, variant = \"outline\", ...rest },\n    ref,\n  ) {\n    const context = useDialogContext(\"DialogClose\");\n\n    if (asChild && isValidElement(children)) {\n      const child = children as ClonableChild;\n      return cloneElement(child, {\n        onClick: composeClick(child, () => context.setOpen(false)),\n      });\n    }\n\n    return (\n      <Button\n        ref={ref}\n        variant={variant}\n        {...rest}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(false);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\n/* -------------------------------------------------------------------------- */\n/*                                   Content                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface DialogContentProps {\n  children: ReactNode;\n  className?: string;\n  /** Class for the dimmed, blurred backdrop. */\n  overlayClassName?: string;\n  /** Show the corner close (X) button. Default true for Dialog, false for AlertDialog. */\n  showCloseButton?: boolean;\n  closeLabel?: string;\n  /**\n   * Close when the backdrop is pressed. Default true for Dialog; AlertDialog\n   * ignores it and never closes on an outside press.\n   */\n  closeOnOutsideClick?: boolean;\n  /** Element to focus on open. Falls back to `[data-dialog-autofocus]`, then the first focusable. */\n  initialFocus?: RefObject<HTMLElement | null>;\n  /** Become a bottom-anchored sheet below the `sm` breakpoint. Default true. */\n  sheetOnMobile?: boolean;\n  /** Accessible name, when the content has no `DialogTitle`. */\n  \"aria-label\"?: string;\n}\n\nconst ENTER = { duration: 0.22, ease: EASE_OUT } as const;\nconst EXIT = { duration: 0.14, ease: EASE_OUT } as const;\n\nfunction DialogContentInner({\n  children,\n  className,\n  overlayClassName,\n  showCloseButton,\n  closeLabel = \"Close\",\n  closeOnOutsideClick = true,\n  initialFocus,\n  sheetOnMobile = true,\n  \"aria-label\": ariaLabel,\n}: DialogContentProps) {\n  const context = useDialogContext(\"DialogContent\");\n  const reduce = useReducedMotion() ?? false;\n  const isPresent = useIsPresent();\n  const belowSm = useBelowSm();\n  const sheet = sheetOnMobile && belowSm;\n  const panelRef = useRef<HTMLDivElement>(null);\n  const stackId = useId();\n  const { setOpen } = context;\n  // Read through a ref so a new onOpenChange identity (inline handlers) does\n  // not re-run the focus/scroll-lock effect and yank focus mid-typing.\n  const setOpenRef = useRef(setOpen);\n  const initialFocusRef = useRef(initialFocus);\n  useEffect(() => {\n    setOpenRef.current = setOpen;\n    initialFocusRef.current = initialFocus;\n  });\n  const dismissOutside = closeOnOutsideClick && !context.alert;\n  const withClose = showCloseButton ?? !context.alert;\n\n  // Scroll lock, stack registration, initial focus, focus trap, Escape and\n  // focus return. Runs while this panel is mounted *and* present, so the\n  // exit animation already behaves like a closed dialog.\n  useEffect(() => {\n    if (!isPresent) return;\n    const returnTo =\n      document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    dialogStack.push(stackId);\n    lockScroll();\n\n    const frame = requestAnimationFrame(() => {\n      const panel = panelRef.current;\n      if (!panel) return;\n      const target =\n        initialFocusRef.current?.current ??\n        panel.querySelector<HTMLElement>(\"[data-dialog-autofocus]\") ??\n        getFocusable(panel).find((el) => !el.hasAttribute(\"data-dialog-close-x\")) ??\n        panel;\n      target.focus({ preventScroll: true });\n    });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (dialogStack[dialogStack.length - 1] !== stackId) return;\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        setOpenRef.current(false);\n        return;\n      }\n      if (event.key !== \"Tab\") return;\n      const panel = panelRef.current;\n      const focusable = getFocusable(panel);\n      if (focusable.length === 0) {\n        event.preventDefault();\n        panel?.focus();\n        return;\n      }\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      const outside = !panel?.contains(active);\n      if (event.shiftKey && (active === first || active === panel || outside)) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && (active === last || outside)) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"keydown\", onKeyDown);\n      const index = dialogStack.lastIndexOf(stackId);\n      if (index !== -1) dialogStack.splice(index, 1);\n      unlockScroll();\n      if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });\n    };\n  }, [isPresent, stackId]);\n\n  const panelInitial = reduce\n    ? { opacity: 0 }\n    : sheet\n      ? { opacity: 0, y: \"100%\" }\n      : { opacity: 0, scale: 0.96 };\n  const panelExit = reduce\n    ? { opacity: 0, transition: EXIT }\n    : sheet\n      ? { opacity: 0, y: \"100%\", transition: { duration: 0.2, ease: EASE_OUT } }\n      : { opacity: 0, scale: 0.97, transition: EXIT };\n\n  return (\n    <div\n      className=\"pointer-events-none fixed inset-0 z-[100]\"\n      style={{ pointerEvents: isPresent ? undefined : \"none\" }}\n    >\n      <motion.button\n        type=\"button\"\n        aria-label={closeLabel}\n        tabIndex={-1}\n        disabled={!dismissOutside}\n        onClick={() => setOpen(false)}\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: EXIT }}\n        transition={ENTER}\n        className={cn(\n          \"pointer-events-auto absolute inset-0 h-full w-full cursor-default bg-black/35 backdrop-blur-sm disabled:cursor-default dark:bg-black/55\",\n          overlayClassName,\n        )}\n      />\n\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-0 flex justify-center\",\n          sheet ? \"items-end\" : \"items-center p-4\",\n        )}\n      >\n        <motion.div\n          ref={panelRef}\n          id={context.contentId}\n          role={context.alert ? \"alertdialog\" : \"dialog\"}\n          aria-modal=\"true\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabel ? undefined : context.titleId}\n          aria-describedby={context.hasDescription ? context.descriptionId : undefined}\n          tabIndex={-1}\n          initial={panelInitial}\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          exit={panelExit}\n          transition={reduce ? ENTER : sheet ? { duration: 0.32, ease: EASE_OUT } : ENTER}\n          style={{ pointerEvents: isPresent ? \"auto\" : \"none\" }}\n          className={cn(\n            \"relative flex w-full flex-col gap-5 overflow-y-auto border-border bg-background p-6 text-foreground shadow-2xl outline-none\",\n            \"[scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n            sheet\n              ? \"max-h-[88dvh] rounded-t-3xl border-t pb-[max(1.5rem,env(safe-area-inset-bottom))]\"\n              : \"max-h-[calc(100dvh-2rem)] max-w-md rounded-3xl border\",\n            className,\n          )}\n        >\n          {sheet ? (\n            <span\n              aria-hidden=\"true\"\n              className=\"mx-auto -mt-2 -mb-1 h-1 w-10 shrink-0 rounded-full bg-foreground/15\"\n            />\n          ) : null}\n          {children}\n          {withClose ? (\n            <button\n              type=\"button\"\n              aria-label={closeLabel}\n              data-dialog-close-x=\"\"\n              onClick={() => setOpen(false)}\n              className=\"absolute top-4 right-4 inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <X className=\"size-4\" aria-hidden=\"true\" />\n            </button>\n          ) : null}\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * The modal surface. Portals to `document.body`, fades a blurred backdrop in,\n * scales the panel from 0.96 (fade only under reduced motion), traps focus,\n * closes on Escape and returns focus to whatever opened it.\n */\nexport function DialogContent(props: DialogContentProps) {\n  const context = useDialogContext(\"DialogContent\");\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  if (!mounted) return null;\n  return createPortal(\n    <AnimatePresence>\n      {context.open ? <DialogContentInner key=\"dialog\" {...props} /> : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                Text & layout                               */\n/* -------------------------------------------------------------------------- */\n\nexport function DialogHeader({\n  className,\n  ...props\n}: ComponentPropsWithoutRef<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5 pr-8 text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function DialogFooter({\n  className,\n  ...props\n}: ComponentPropsWithoutRef<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function DialogTitle({\n  className,\n  ...props\n}: Omit<ComponentPropsWithoutRef<\"h2\">, \"id\">) {\n  const context = useDialogContext(\"DialogTitle\");\n  return (\n    <h2\n      id={context.titleId}\n      className={cn(\"text-lg font-semibold tracking-tight\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function DialogDescription({\n  className,\n  ...props\n}: Omit<ComponentPropsWithoutRef<\"p\">, \"id\">) {\n  const context = useDialogContext(\"DialogDescription\");\n  const { setHasDescription } = context;\n  useEffect(() => {\n    setHasDescription(true);\n    return () => setHasDescription(false);\n  }, [setHasDescription]);\n  return (\n    <p\n      id={context.descriptionId}\n      className={cn(\"text-sm leading-relaxed text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              AlertDialog parts                             */\n/* -------------------------------------------------------------------------- */\n\nexport const AlertDialogTrigger = DialogTrigger;\nexport type AlertDialogTriggerProps = DialogTriggerProps;\nexport const AlertDialogHeader = DialogHeader;\nexport const AlertDialogFooter = DialogFooter;\nexport const AlertDialogTitle = DialogTitle;\nexport const AlertDialogDescription = DialogDescription;\n\nexport type AlertDialogContentProps = Omit<\n  DialogContentProps,\n  \"closeOnOutsideClick\"\n>;\n\nexport function AlertDialogContent(props: AlertDialogContentProps) {\n  return <DialogContent {...props} />;\n}\n\nexport interface AlertDialogCancelProps extends ButtonProps {}\n\n/** The safe choice. Receives initial focus when the AlertDialog opens. */\nexport const AlertDialogCancel = forwardRef<HTMLButtonElement, AlertDialogCancelProps>(\n  function AlertDialogCancel({ children = \"Cancel\", ...props }, ref) {\n    return (\n      <DialogClose ref={ref} data-dialog-autofocus=\"\" {...props}>\n        {children}\n      </DialogClose>\n    );\n  },\n);\n\nexport interface AlertDialogActionProps extends ButtonProps {\n  /** `destructive` (default) paints the button in the destructive token. */\n  tone?: \"destructive\" | \"default\";\n}\n\n/**\n * The confirming action. Closes the dialog after `onClick` unless the handler\n * calls `event.preventDefault()`. With `confirmText` set on the AlertDialog it\n * stays `aria-disabled` until the text matches; pressing it early shakes the\n * confirm field and moves focus there.\n */\nexport const AlertDialogAction = forwardRef<HTMLButtonElement, AlertDialogActionProps>(\n  function AlertDialogAction(\n    { tone = \"destructive\", className, onClick, children, ...props },\n    ref,\n  ) {\n    const context = useDialogContext(\"AlertDialogAction\");\n    const reduce = useReducedMotion();\n    const blocked = !context.confirmValid;\n\n    return (\n      <Button\n        ref={ref}\n        variant=\"primary\"\n        aria-disabled={blocked || undefined}\n        data-alert-dialog-action=\"\"\n        {...props}\n        className={cn(\n          tone === \"destructive\" &&\n            \"bg-destructive text-white hover:bg-destructive/90\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          blocked && \"opacity-55\",\n          blocked &&\n            (tone === \"destructive\" ? \"hover:bg-destructive\" : \"hover:bg-primary\"),\n          className,\n        )}\n        onClick={(event) => {\n          if (blocked) {\n            event.preventDefault();\n            // The field's own error shake plays the first time the message\n            // appears; later attempts shake it here instead.\n            const errorShowing = context.confirmAttempted;\n            context.setConfirmAttempted(true);\n            const field = context.confirmFieldRef.current;\n            if (field && errorShowing && !reduce) {\n              animate(field, { x: SHAKE_X }, { duration: 0.4 });\n            }\n            context.confirmInputRef.current?.focus();\n            return;\n          }\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(false);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\nexport interface AlertDialogConfirmInputProps\n  extends Omit<InputProps, \"value\" | \"defaultValue\" | \"onChange\" | \"error\"> {\n  /** Message shown when confirming with a mismatch. */\n  mismatchMessage?: string;\n}\n\n/**\n * Type-to-confirm field for an `AlertDialog` with `confirmText`. Enter submits\n * through `AlertDialogAction`'s rules.\n */\nexport function AlertDialogConfirmInput({\n  label,\n  mismatchMessage = \"That doesn't match. Check the spelling and try again.\",\n  className,\n  onKeyDown,\n  ...props\n}: AlertDialogConfirmInputProps) {\n  const context = useDialogContext(\"AlertDialogConfirmInput\");\n  const target = context.confirmText ?? \"\";\n  const showError = context.confirmAttempted && !context.confirmValid;\n\n  return (\n    <div ref={context.confirmFieldRef} className={className}>\n      <Input\n        ref={context.confirmInputRef}\n        label={label ?? `Type \"${target}\" to confirm`}\n        autoComplete=\"off\"\n        autoCapitalize=\"off\"\n        spellCheck={false}\n        {...props}\n        value={context.confirmValue}\n        onChange={context.setConfirmValue}\n        error={showError ? mismatchMessage : undefined}\n        success={target !== \"\" && context.confirmValid}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (event.defaultPrevented || event.key !== \"Enter\") return;\n          event.preventDefault();\n          const panel = event.currentTarget.closest<HTMLElement>(\"[role='alertdialog']\");\n          panel?.querySelector<HTMLButtonElement>(\"[data-alert-dialog-action]\")?.click();\n        }}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\n"},{"path":"components/motion/input.tsx","type":"registry:component","target":"@components/motion/input.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  leftIcon?: string;\n  rightIcon?: string;\n  successIcon?: string;\n  errorMessage?: string;\n};\n\nexport interface InputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> {\n  label?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  success?: boolean;\n  leftIcon?: ReactNode;\n  rightIcon?: ReactNode;\n  className?: string;\n  classNames?: InputClassNames;\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  {\n    label,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onFocus,\n    onBlur,\n    error,\n    success,\n    leftIcon,\n    rightIcon,\n    className,\n    classNames,\n    disabled,\n    id: idProp,\n    type,\n    ...rest\n  },\n  ref,\n) {\n  const reactId = useId();\n  const id = idProp ?? reactId;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [focused, setFocused] = useState(false);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" ? error : null;\n\n  // Right edge shows the success check, otherwise the caller's right icon.\n  const rightSlot = success ? null : rightIcon;\n\n  // Shake the field when an error appears.\n  useEffect(() => {\n    if (!fieldRef.current || reduce || !hasError) return;\n    animate(\n      fieldRef.current,\n      { x: [0, -6, 6, -4, 4, -2, 0] },\n      { duration: 0.45 },\n    );\n  }, [hasError, reduce]);\n\n  const handleChange = (next: string) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n    >\n      {label ? (\n        <label\n          htmlFor={id}\n          className={cn(\n            \"px-1 text-sm font-medium text-foreground\",\n            classNames?.label,\n          )}\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={\n          hasError\n            ? \"error\"\n            : success\n              ? \"success\"\n              : focused\n                ? \"focused\"\n                : \"idle\"\n        }\n        className={cn(\n          \"relative h-11 overflow-hidden rounded-full border transition-colors duration-200\",\n          \"border-border\",\n          focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n          hasError && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"opacity-60\",\n          classNames?.field,\n        )}\n      >\n        {leftIcon ? (\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.leftIcon,\n            )}\n          >\n            {leftIcon}\n          </span>\n        ) : null}\n\n        <input\n          ref={ref}\n          id={id}\n          type={type}\n          value={value}\n          disabled={disabled}\n          aria-invalid={hasError || undefined}\n          aria-describedby={errorMessage ? `${id}-error` : undefined}\n          {...rest}\n          onChange={(e) => handleChange(e.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none\",\n            \"placeholder:text-muted-foreground/60\",\n            leftIcon ? \"pl-10\" : \"pl-3.5\",\n            rightSlot || success ? \"pr-10\" : \"pr-3.5\",\n            disabled && \"cursor-not-allowed\",\n            classNames?.input,\n          )}\n        />\n\n        {success ? (\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            className={cn(\n              \"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)\",\n              classNames?.successIcon,\n            )}\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={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.35, ease: \"easeOut\" }}\n            />\n          </motion.svg>\n        ) : rightSlot ? (\n          <span\n            className={cn(\n              \"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.rightIcon,\n            )}\n          >\n            {rightSlot}\n          </span>\n        ) : null}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {errorMessage ? (\n          <motion.p\n            id={`${id}-error`}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {errorMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\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\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\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  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\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 { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\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\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\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          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\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          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          // The ripple layer clips itself, so on a touch screen the button can\n          // stop clipping and let its enlarged hit area overhang.\n          ripple && \"overflow-hidden pointer-coarse:overflow-visible\",\n          TOUCH_TARGET_CLASS,\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.05, 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\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\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 { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\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  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\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        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\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":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\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"}]}