{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drawer",
  "title": "Drawer",
  "description": "A drawer component.",
  "dependencies": [
    "class-variance-authority",
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "@cubby-ui/button",
    "@cubby-ui/scroll-area",
    "@cubby-ui/elevated"
  ],
  "files": [
    {
      "path": "registry/default/drawer/drawer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  Dialog as BaseDialog,\n  type DialogRootChangeEventDetails,\n} from \"@base-ui/react/dialog\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/registry/default/button/button\";\nimport {\n  INNER_EDGE_FROM_ATTACH_SIDE,\n  elevatedSurface,\n  flushSurface,\n  type SurfaceLevel,\n} from \"@/registry/default/lib/elevated\";\nimport {\n  ScrollArea,\n  type ScrollAreaProps,\n} from \"@/registry/default/scroll-area/scroll-area\";\n\nimport \"./drawer.css\";\n\nimport type { SnapPoint, DrawerDirection } from \"./lib/drawer-utils\";\nimport {\n  DIRECTION_CONFIG,\n  parsePixelValue,\n  findSnapPointIndex,\n  getSnapPointValue,\n  snapPointToRatio,\n  supportsScrollTimeline,\n  supportsScrollState,\n} from \"./lib/drawer-utils\";\nimport { useScrollSnap } from \"./hooks/use-scroll-snap\";\nimport { useVirtualKeyboard } from \"./hooks/use-virtual-keyboard\";\nimport { useVisualViewportHeight } from \"./hooks/use-visual-viewport-height\";\n\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Cancel01Icon } from \"@hugeicons/core-free-icons\";\nexport type { SnapPoint, DrawerDirection };\n\nconst createDrawerHandle = BaseDialog.createHandle;\n\nconst drawerContentVariants = cva(\n  [\n    \"text-popover-foreground flex flex-col\",\n    \"relative\",\n    \"ease-[cubic-bezier(.32,.72,0,1)] transition-[transform,scale,translate] duration-400  will-change-transform\",\n    \"motion-reduce:transition-none\",\n    // Nested drawer support: scale down parent when child opens (interpolated during drag)\n    \"scale-[calc(1-0.05*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))]\",\n    // Disable transitions on parent while child is being dragged\n    \"data-[nested-dragging]:transition-none\",\n    // Nested drawer support: overlay dim effect — uses ::before to stay clear of the elevation rim's ::after\n    \"before:pointer-events-none before:absolute before:inset-0 before:z-50 before:hidden before:rounded-[inherit] before:bg-black/15 before:opacity-0 before:transition-[opacity,display] before:transition-discrete before:duration-300\",\n    \"data-nested-dialog-open:before:block data-nested-dialog-open:before:opacity-100\",\n    \"starting:data-nested-dialog-open:before:opacity-0\",\n  ],\n  {\n    variants: {\n      variant: {\n        // `default` no longer needs its own shadow/ring — the elevation system provides that\n        default: \"\",\n        floating: [\"m-4 overflow-clip rounded-2xl\"],\n      },\n      direction: {\n        bottom: \"\",\n        top: \"\",\n        right: \"\",\n        left: \"\",\n      },\n    },\n    compoundVariants: [\n      // Default variant - direction-specific sizing, rounding, and nesting origin/offset\n      {\n        variant: \"default\",\n        direction: \"bottom\",\n        class:\n          \"origin-bottom mx-auto max-h-[95dvh] w-full max-w-full rounded-t-2xl -translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-y-[var(--drawer-offset)] [&[data-ending-style]]:translate-y-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"default\",\n        direction: \"top\",\n        class:\n          \"origin-top mx-auto max-h-[95dvh] w-full max-w-full rounded-b-2xl translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-y-[var(--drawer-offset)] [&[data-ending-style]]:-translate-y-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"default\",\n        direction: \"right\",\n        class:\n          \"origin-right max-w-screen w-screen sm:max-w-md -translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-x-[var(--drawer-offset)] [&[data-ending-style]]:translate-x-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"default\",\n        direction: \"left\",\n        class:\n          \"origin-left max-w-screen w-screen sm:max-w-md translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-x-[var(--drawer-offset)] [&[data-ending-style]]:-translate-x-[var(--drawer-offset)]\",\n      },\n      // Floating variant - direction-specific sizing, transforms, and nesting origin/offset\n      {\n        variant: \"floating\",\n        direction: \"bottom\",\n        class:\n          \"origin-bottom mx-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] -translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-y-[var(--drawer-offset)] [&[data-ending-style]]:translate-y-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"floating\",\n        direction: \"top\",\n        class:\n          \"origin-top mx-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] translate-y-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-y-[var(--drawer-offset)] [&[data-ending-style]]:-translate-y-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"floating\",\n        direction: \"right\",\n        class:\n          \"origin-right h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-md -translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:translate-x-[var(--drawer-offset)] [&[data-ending-style]]:translate-x-[var(--drawer-offset)]\",\n      },\n      {\n        variant: \"floating\",\n        direction: \"left\",\n        class:\n          \"origin-left h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-md translate-x-[calc(1.5rem*max(0,var(--nested-dialogs,0)-var(--nested-drag-progress,0)))] [&[data-starting-style]]:-translate-x-[var(--drawer-offset)] [&[data-ending-style]]:-translate-x-[var(--drawer-offset)]\",\n      },\n    ],\n    defaultVariants: {\n      variant: \"default\",\n      direction: \"bottom\",\n    },\n  },\n);\n\nconst drawerTrackVariants = cva(\"pointer-events-none relative flex\", {\n  variants: {\n    direction: {\n      bottom: \"w-full flex-col justify-end\",\n      top: \"w-full flex-col justify-start\",\n      right: \"h-full flex-row justify-end\",\n      left: \"h-full flex-row justify-start\",\n    },\n  },\n  defaultVariants: {\n    direction: \"bottom\",\n  },\n});\n\n// iOS 26 Safari: Fixed elements for nav bar color detection (must be within 3px of edge, ≥80% wide, ≥3px tall)\nconst SafariNavColorDetectors = (\n  <>\n    <div\n      aria-hidden=\"true\"\n      className=\"bg-popover pointer-events-none fixed inset-x-0 bottom-0 hidden h-10 bg-clip-text [@supports(-webkit-touch-callout:none)]:block\"\n    />\n    <div\n      aria-hidden=\"true\"\n      className=\"bg-popover pointer-events-none fixed inset-x-0 top-0 hidden h-10 bg-clip-text [@supports(-webkit-touch-callout:none)]:block\"\n    />\n  </>\n);\n\nconst backdropAnimationStyles: Record<DrawerDirection, string> = {\n  bottom:\n    \"fill-mode-[both] [animation-name:drawer-backdrop-fade] [animation-range:entry_0%_entry_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]\",\n  right:\n    \"fill-mode-[both] [animation-name:drawer-backdrop-fade] [animation-range:entry_0%_entry_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]\",\n  top: \"fill-mode-[both] direction-[reverse] [animation-name:drawer-backdrop-fade] [animation-range:exit_0%_exit_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]\",\n  left: \"fill-mode-[both] direction-[reverse] [animation-name:drawer-backdrop-fade] [animation-range:exit_0%_exit_100%] [animation-timeline:--drawer-panel] [animation-timing-function:linear]\",\n};\n\n// Allows child drawers to propagate drag progress to parent for smooth stacking effects.\ninterface DrawerNestingContextValue {\n  /** Set the child's drag progress (0 = open, 1 = closed) on the parent popup */\n  setNestedDragProgress: (progress: number) => void;\n  /** Toggle transition-disabling attribute on the parent popup */\n  setNestedDragging: (dragging: boolean) => void;\n  /** Remove the drag progress CSS variable from the parent popup */\n  clearNestedDragProgress: () => void;\n}\n\nconst DrawerNestingContext =\n  React.createContext<DrawerNestingContextValue | null>(null);\n\ntype DrawerVariant = \"default\" | \"floating\";\n\ninterface DrawerConfigContextValue {\n  direction: DrawerDirection;\n  variant: DrawerVariant;\n  snapPoints: SnapPoint[];\n  dismissible: boolean;\n  modal: boolean | \"trap-focus\";\n  isVertical: boolean;\n  sequentialSnap: boolean;\n  repositionInputs: boolean;\n}\n\ninterface DrawerScrollContextValue {\n  isDragging: boolean;\n  dragProgress: number;\n  snapProgress: number;\n}\n\ninterface DrawerControlContextValue {\n  open: boolean;\n  onOpenChange: (\n    open: boolean,\n    eventDetails?: DialogRootChangeEventDetails,\n  ) => void;\n  dismissViaSwipe: () => void;\n  activeSnapPoint: SnapPoint;\n  setActiveSnapPoint: (snapPoint: SnapPoint) => void;\n  contentSize: number | null;\n  setContentSize: (size: number | null) => void;\n  isAnimating: boolean;\n  immediateClose: boolean;\n  setImmediateClose: (value: boolean) => void;\n  setIsDragging: (dragging: boolean) => void;\n  setDragProgress: (progress: number) => void;\n  setSnapProgress: (progress: number) => void;\n}\n\nconst DrawerConfigContext =\n  React.createContext<DrawerConfigContextValue | null>(null);\nconst DrawerScrollContext =\n  React.createContext<DrawerScrollContextValue | null>(null);\nconst DrawerControlContext =\n  React.createContext<DrawerControlContextValue | null>(null);\n\nfunction useDrawerConfig() {\n  const context = React.useContext(DrawerConfigContext);\n  if (!context) {\n    throw new Error(\"Drawer components must be used within a <Drawer />\");\n  }\n  return context;\n}\n\n/** Subscribes to high-frequency scroll/drag state — re-renders during drag. */\nfunction useDrawerScroll() {\n  const context = React.useContext(DrawerScrollContext);\n  if (!context) {\n    throw new Error(\"Drawer components must be used within a <Drawer />\");\n  }\n  return context;\n}\n\n/** Low-frequency control state and stable setters — does not re-render during drag. */\nfunction useDrawerControl() {\n  const context = React.useContext(DrawerControlContext);\n  if (!context) {\n    throw new Error(\"Drawer components must be used within a <Drawer />\");\n  }\n  return context;\n}\n\n/** Combines scroll + control contexts. For fewer re-renders use `useDrawerControl()` or `useDrawerScroll()`. */\nfunction useDrawerAnimation() {\n  return { ...useDrawerScroll(), ...useDrawerControl() };\n}\n\n/** All drawer context values. For fewer re-renders use `useDrawerConfig`, `useDrawerControl`, or `useDrawerScroll`. */\nfunction useDrawer() {\n  return { ...useDrawerConfig(), ...useDrawerScroll(), ...useDrawerControl() };\n}\n\ninterface DrawerRenderProps {\n  /** 0 = first snap, 1 = last snap */\n  snapProgress: number;\n  /** 0 = open, 1 = closed */\n  dragProgress: number;\n  isDragging: boolean;\n  activeSnapPoint: SnapPoint;\n}\n\ninterface DrawerProps extends Omit<\n  React.ComponentProps<typeof BaseDialog.Root>,\n  \"children\" | \"onOpenChange\"\n> {\n  direction?: DrawerDirection;\n  variant?: DrawerVariant;\n  /** Percentages (0-1) or pixel strings (e.g., \"200px\") */\n  snapPoints?: SnapPoint[];\n  defaultSnapPoint?: SnapPoint;\n  activeSnapPoint?: SnapPoint | null;\n  onActiveSnapPointChange?: (snapPoint: SnapPoint) => void;\n  dismissible?: boolean;\n  /** `true` = modal, `\"trap-focus\"` = focus trapped but no scroll lock, `false` = non-modal */\n  modal?: boolean | \"trap-focus\";\n  /** Prevents skipping snap points during fast swipes */\n  sequentialSnap?: boolean;\n  /** Repositions drawer when virtual keyboard appears (bottom only) */\n  repositionInputs?: boolean;\n  /**\n   * Event handler called when the drawer is opened or closed.\n   * `eventDetails` may be undefined for internal close actions (e.g., DrawerClose, DrawerHandle).\n   */\n  onOpenChange?: (\n    open: boolean,\n    eventDetails?: DialogRootChangeEventDetails,\n  ) => void;\n  children?: React.ReactNode | ((props: DrawerRenderProps) => React.ReactNode);\n}\n\nconst DEFAULT_SNAP_POINTS: SnapPoint[] = [1];\n\nfunction Drawer({\n  direction = \"bottom\",\n  variant = \"default\",\n  snapPoints = DEFAULT_SNAP_POINTS,\n  defaultSnapPoint,\n  activeSnapPoint: controlledSnapPoint,\n  onActiveSnapPointChange,\n  dismissible = true,\n  modal = true,\n  sequentialSnap = false,\n  repositionInputs = false,\n  open: controlledOpen,\n  defaultOpen,\n  onOpenChange: controlledOnOpenChange,\n  children,\n  ...props\n}: DrawerProps) {\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(\n    defaultOpen ?? false,\n  );\n  const isOpenControlled = controlledOpen !== undefined;\n  const open = isOpenControlled ? controlledOpen : uncontrolledOpen;\n\n  const defaultSnapPointIndex =\n    defaultSnapPoint !== undefined\n      ? findSnapPointIndex(snapPoints, defaultSnapPoint)\n      : 0;\n\n  const [internalSnapPointIndex, setInternalSnapPointIndex] = React.useState(\n    defaultSnapPointIndex,\n  );\n\n  const isSnapPointControlled = controlledSnapPoint !== undefined;\n  const activeSnapPointIndex = isSnapPointControlled\n    ? findSnapPointIndex(snapPoints, controlledSnapPoint)\n    : internalSnapPointIndex;\n\n  const activeSnapPointValue = getSnapPointValue(\n    snapPoints,\n    activeSnapPointIndex,\n  );\n\n  const [isDragging, setIsDragging] = React.useState(false);\n  const [dragProgress, setDragProgress] = React.useState(1);\n  const [snapProgress, setSnapProgress] = React.useState(0);\n  const [contentSize, setContentSize] = React.useState<number | null>(null);\n  const [isAnimating, setIsAnimating] = React.useState(false);\n  const [immediateClose, setImmediateClose] = React.useState(false);\n\n  // Base UI doesn't call onOpenChange for external prop changes, so handleOpenChange(true)\n  // never fires — set isAnimating here to block dismiss gestures during the enter transition.\n  const prevOpenRef = React.useRef(open);\n  React.useLayoutEffect(() => {\n    if (open && !prevOpenRef.current) {\n      setIsAnimating(true);\n      setImmediateClose(false);\n    }\n    prevOpenRef.current = open;\n  }, [open]);\n\n  const { isVertical } = DIRECTION_CONFIG[direction];\n\n  const isDraggingRef = React.useRef(false);\n  const swipeDismissRef = React.useRef(false);\n  React.useLayoutEffect(() => {\n    isDraggingRef.current = isDragging;\n  });\n\n  const handleOpenChangeComplete = React.useCallback(() => {\n    setIsAnimating(false);\n    setImmediateClose(false);\n  }, []);\n\n  const handleOpenChange = React.useCallback(\n    (nextOpen: boolean, eventDetails?: DialogRootChangeEventDetails) => {\n      // Prevent closing while scrolling, but allow explicit swipe dismiss\n      if (!nextOpen && isDraggingRef.current && !swipeDismissRef.current) {\n        return;\n      }\n      swipeDismissRef.current = false;\n\n      setIsAnimating(true);\n\n      if (!isOpenControlled) {\n        setUncontrolledOpen(nextOpen);\n      }\n      controlledOnOpenChange?.(nextOpen, eventDetails);\n\n      if (nextOpen && !isSnapPointControlled) {\n        setInternalSnapPointIndex(defaultSnapPointIndex);\n        const defaultSnapValue = getSnapPointValue(\n          snapPoints,\n          defaultSnapPointIndex,\n        );\n        onActiveSnapPointChange?.(defaultSnapValue);\n      }\n\n      if (nextOpen) {\n        setDragProgress(1);\n        setIsDragging(false);\n        setImmediateClose(false);\n        if (!isSnapPointControlled) {\n          const progress =\n            snapPoints.length > 1\n              ? defaultSnapPointIndex / (snapPoints.length - 1)\n              : 0;\n          setSnapProgress(progress);\n        }\n      }\n    },\n    [\n      isOpenControlled,\n      controlledOnOpenChange,\n      snapPoints,\n      isSnapPointControlled,\n      onActiveSnapPointChange,\n      defaultSnapPointIndex,\n    ],\n  );\n\n  const dismissViaSwipe = React.useCallback(() => {\n    swipeDismissRef.current = true;\n    handleOpenChange(false);\n  }, [handleOpenChange]);\n\n  const setActiveSnapPoint = React.useCallback(\n    (value: SnapPoint) => {\n      const index = findSnapPointIndex(snapPoints, value);\n      if (!isSnapPointControlled) {\n        setInternalSnapPointIndex(index);\n      }\n      onActiveSnapPointChange?.(value);\n    },\n    [snapPoints, isSnapPointControlled, onActiveSnapPointChange],\n  );\n\n  const configValue = React.useMemo(\n    () => ({\n      direction,\n      variant,\n      snapPoints,\n      dismissible,\n      modal,\n      isVertical,\n      sequentialSnap,\n      repositionInputs,\n    }),\n    [\n      direction,\n      variant,\n      snapPoints,\n      dismissible,\n      modal,\n      isVertical,\n      sequentialSnap,\n      repositionInputs,\n    ],\n  );\n\n  const scrollValue = React.useMemo(\n    () => ({\n      isDragging,\n      dragProgress,\n      snapProgress,\n    }),\n    [isDragging, dragProgress, snapProgress],\n  );\n\n  const controlValue = React.useMemo(\n    () => ({\n      open,\n      onOpenChange: handleOpenChange,\n      dismissViaSwipe,\n      activeSnapPoint: activeSnapPointValue,\n      setActiveSnapPoint,\n      contentSize,\n      setContentSize,\n      isAnimating,\n      immediateClose,\n      setImmediateClose,\n      setIsDragging,\n      setDragProgress,\n      setSnapProgress,\n    }),\n    [\n      open,\n      handleOpenChange,\n      dismissViaSwipe,\n      activeSnapPointValue,\n      setActiveSnapPoint,\n      contentSize,\n      isAnimating,\n      immediateClose,\n    ],\n  );\n\n  const resolvedChildren =\n    typeof children === \"function\"\n      ? children({\n          snapProgress,\n          dragProgress,\n          isDragging,\n          activeSnapPoint: activeSnapPointValue,\n        })\n      : children;\n\n  return (\n    <DrawerConfigContext.Provider value={configValue}>\n      <DrawerScrollContext.Provider value={scrollValue}>\n        <DrawerControlContext.Provider value={controlValue}>\n          <BaseDialog.Root\n            data-slot=\"drawer\"\n            open={open}\n            onOpenChange={handleOpenChange}\n            onOpenChangeComplete={handleOpenChangeComplete}\n            modal={modal}\n            disablePointerDismissal={isAnimating || modal !== true}\n            {...props}\n          >\n            {resolvedChildren}\n          </BaseDialog.Root>\n        </DrawerControlContext.Provider>\n      </DrawerScrollContext.Provider>\n    </DrawerConfigContext.Provider>\n  );\n}\n\nfunction DrawerTrigger({\n  ...props\n}: React.ComponentProps<typeof BaseDialog.Trigger>) {\n  return <BaseDialog.Trigger data-slot=\"drawer-trigger\" {...props} />;\n}\n\ninterface DrawerCloseProps extends useRender.ComponentProps<\"button\"> {\n  onClick?: (event: React.MouseEvent) => void;\n}\n\nfunction DrawerClose({\n  onClick,\n  className,\n  render,\n  ...props\n}: DrawerCloseProps) {\n  const { onOpenChange, isAnimating } = useDrawerControl();\n\n  const handleClick = React.useCallback(\n    (event: React.MouseEvent) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      if (isAnimating) return;\n      onOpenChange(false);\n    },\n    [onClick, onOpenChange, isAnimating],\n  );\n\n  const defaultProps = {\n    \"data-slot\": \"drawer-close\",\n    type: \"button\" as const,\n    className,\n    onClick: handleClick,\n  };\n\n  const element = useRender({\n    defaultTagName: \"button\",\n    render,\n    props: mergeProps<\"button\">(defaultProps, props),\n  });\n\n  return element;\n}\n\nfunction DrawerPortal({\n  ...props\n}: React.ComponentProps<typeof BaseDialog.Portal>) {\n  return <BaseDialog.Portal data-slot=\"drawer-portal\" {...props} />;\n}\n\ninterface DrawerContentProps extends BaseDialog.Popup.Props {\n  footerVariant?: \"default\" | \"inset\";\n  showCloseButton?: boolean;\n  /** Surface elevation level for the drawer bg (1-8). Defaults to 5 — the dialog/sheet/drawer tier. */\n  level?: SurfaceLevel;\n  /** Shadow weight (1-8). Pinned to 5 by default. */\n  shadowLevel?: SurfaceLevel;\n}\n\nfunction DrawerContent({\n  initialFocus,\n  finalFocus,\n  footerVariant = \"default\",\n  showCloseButton,\n  level = 5,\n  shadowLevel = 5,\n  ...props\n}: DrawerContentProps) {\n  return (\n    <DrawerPortal>\n      <DrawerContentInner\n        initialFocus={initialFocus}\n        finalFocus={finalFocus}\n        footerVariant={footerVariant}\n        showCloseButton={showCloseButton}\n        level={level}\n        shadowLevel={shadowLevel}\n        {...props}\n      />\n    </DrawerPortal>\n  );\n}\n\nfunction useContentMeasurement(\n  isVertical: boolean,\n  floatingMargin: number,\n  setContentSize: (size: number | null) => void,\n) {\n  const observerRef = React.useRef<ResizeObserver | null>(null);\n\n  const measureRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      observerRef.current?.disconnect();\n      observerRef.current = null;\n\n      if (!node) return;\n\n      const measure = () => {\n        const baseSize = isVertical ? node.offsetHeight : node.offsetWidth;\n        setContentSize(baseSize + floatingMargin);\n      };\n\n      measure();\n      observerRef.current = new ResizeObserver(measure);\n      observerRef.current.observe(node);\n    },\n    [isVertical, setContentSize, floatingMargin],\n  );\n\n  const popupRef = React.useRef<HTMLDivElement>(null);\n\n  const mergedRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      popupRef.current = node;\n      measureRef(node);\n    },\n    [measureRef],\n  );\n\n  React.useEffect(() => () => observerRef.current?.disconnect(), []);\n\n  return { mergedRef, popupRef };\n}\n\nfunction useDrawerContentStyles({\n  direction,\n  variant,\n  activeSnapPoint,\n  snapPoints,\n  contentSize,\n  isVertical,\n  isInitialized,\n  isAnimating,\n  immediateClose,\n  dismissible,\n  snapProgress,\n  repositionInputs,\n  keyboardHeight,\n  visualViewportHeight,\n}: {\n  direction: DrawerDirection;\n  variant: DrawerVariant;\n  activeSnapPoint: SnapPoint;\n  snapPoints: SnapPoint[];\n  contentSize: number | null;\n  isVertical: boolean;\n  isInitialized: boolean;\n  isAnimating: boolean;\n  immediateClose: boolean;\n  dismissible: boolean;\n  snapProgress: number;\n  repositionInputs: boolean;\n  keyboardHeight: number;\n  visualViewportHeight: number | null;\n}) {\n  const snapPointRatio = React.useMemo(() => {\n    if (typeof activeSnapPoint === \"number\") {\n      return activeSnapPoint;\n    }\n    const pixels = parsePixelValue(activeSnapPoint);\n    if (!pixels || !contentSize) return 1;\n    return pixels / contentSize;\n  }, [activeSnapPoint, contentSize]);\n\n  const firstSnapRatio =\n    contentSize != null\n      ? snapPointToRatio(snapPoints[0], contentSize)\n      : typeof snapPoints[0] === \"number\"\n        ? snapPoints[0]\n        : 1;\n  const lastSnapRatio =\n    contentSize != null\n      ? snapPointToRatio(snapPoints[snapPoints.length - 1], contentSize)\n      : typeof snapPoints[snapPoints.length - 1] === \"number\"\n        ? snapPoints[snapPoints.length - 1]\n        : 1;\n\n  const baseOffset =\n    typeof activeSnapPoint === \"number\"\n      ? `${activeSnapPoint * 100}%`\n      : activeSnapPoint;\n  const animationOffset =\n    variant === \"floating\" ? `calc(${baseOffset} + 1rem)` : baseOffset;\n\n  const targetBackdropOpacity = snapPointRatio;\n\n  const useScrollDrivenAnimation =\n    supportsScrollTimeline && isInitialized && !isAnimating && !immediateClose;\n\n  const viewportStyle = React.useMemo<React.CSSProperties>(\n    () => ({\n      ...(visualViewportHeight != null && {\n        \"--visual-viewport-height\": `${visualViewportHeight}px`,\n      }),\n      ...(repositionInputs && {\n        \"--keyboard-height\": `${keyboardHeight}px`,\n      }),\n      \"--content-size\": `${contentSize ?? 0}px`,\n      \"--dismiss-buffer\": dismissible ? `${(contentSize ?? 0) * 0.3}px` : \"0px\",\n      \"--first-snap-ratio\": firstSnapRatio,\n      \"--last-snap-ratio\": lastSnapRatio,\n      scrollSnapType: isInitialized\n        ? isVertical\n          ? \"y mandatory\"\n          : \"x mandatory\"\n        : \"none\",\n      scrollBehavior: isInitialized ? \"smooth\" : \"auto\",\n      ...(useScrollDrivenAnimation\n        ? {\n            animationName: \"drawer-snap-progress\",\n            animationTimingFunction: \"linear\",\n            animationFillMode: \"both\",\n            animationTimeline: isVertical ? \"scroll(self)\" : \"scroll(self x)\",\n            ...(direction === \"top\" || direction === \"left\"\n              ? {\n                  animationRange: `calc(var(--content-size) * (1 - var(--last-snap-ratio))) calc(var(--content-size) * (1 - var(--first-snap-ratio)))`,\n                  animationDirection: \"reverse\",\n                }\n              : {\n                  animationRange: `calc(var(--dismiss-buffer) + var(--first-snap-ratio) * var(--content-size)) calc(var(--dismiss-buffer) + var(--last-snap-ratio) * var(--content-size))`,\n                }),\n          }\n        : {\n            \"--drawer-snap-progress\": snapProgress,\n          }),\n    }),\n    [\n      visualViewportHeight,\n      repositionInputs,\n      keyboardHeight,\n      contentSize,\n      dismissible,\n      firstSnapRatio,\n      lastSnapRatio,\n      isInitialized,\n      isVertical,\n      useScrollDrivenAnimation,\n      snapProgress,\n      direction,\n    ],\n  );\n\n  const popupStyle = React.useMemo<React.CSSProperties>(\n    () => ({\n      \"--drawer-offset\": animationOffset,\n      ...(supportsScrollTimeline && {\n        viewTimelineName: \"--drawer-panel\",\n        viewTimelineAxis: isVertical ? \"block\" : \"inline\",\n      }),\n    }),\n    [animationOffset, isVertical],\n  );\n\n  return {\n    viewportStyle,\n    popupStyle,\n    useScrollDrivenAnimation,\n    targetBackdropOpacity,\n  };\n}\n\nfunction DrawerContentInner({\n  className,\n  children,\n  footerVariant = \"default\",\n  showCloseButton = false,\n  initialFocus,\n  finalFocus,\n  level = 5,\n  shadowLevel = 5,\n  ...props\n}: DrawerContentProps) {\n  const {\n    direction,\n    variant,\n    snapPoints,\n    dismissible,\n    modal,\n    isVertical,\n    sequentialSnap,\n    repositionInputs,\n  } = useDrawerConfig();\n\n  const { isDragging, dragProgress, snapProgress } = useDrawerScroll();\n\n  const {\n    activeSnapPoint,\n    setActiveSnapPoint,\n    contentSize,\n    setContentSize,\n    setIsDragging,\n    setDragProgress,\n    setSnapProgress,\n    dismissViaSwipe,\n    open,\n    isAnimating,\n    immediateClose,\n    setImmediateClose,\n  } = useDrawerControl();\n\n  const activeSnapPointIndex = findSnapPointIndex(snapPoints, activeSnapPoint);\n\n  const handleSnapPointChange = React.useCallback(\n    (index: number) => {\n      setActiveSnapPoint(getSnapPointValue(snapPoints, index));\n    },\n    [snapPoints, setActiveSnapPoint],\n  );\n\n  const { keyboardHeight, isKeyboardVisible } = useVirtualKeyboard({\n    enabled: direction === \"bottom\",\n  });\n\n  const visualViewportHeight = useVisualViewportHeight({\n    enabled: !isVertical && modal !== true,\n  });\n\n  const handleDismiss = React.useCallback(() => {\n    dismissViaSwipe();\n  }, [dismissViaSwipe]);\n\n  const handleImmediateClose = React.useCallback(() => {\n    setImmediateClose(true);\n  }, [setImmediateClose]);\n\n  const parentNesting = React.useContext(DrawerNestingContext);\n\n  // Skip updates during enter/exit animations — let CSS control the backdrop then.\n  const handleScrollProgress = React.useCallback(\n    (progress: number) => {\n      if (!isAnimating) {\n        setDragProgress(progress);\n        // Update parent's CSS variable for interpolated nesting scale/translate\n        parentNesting?.setNestedDragProgress(progress);\n      }\n    },\n    [isAnimating, setDragProgress, parentNesting],\n  );\n\n  const handleSnapProgress = React.useCallback(\n    (progress: number) => {\n      if (!isAnimating) {\n        setSnapProgress(progress);\n      }\n    },\n    [isAnimating, setSnapProgress],\n  );\n\n  const {\n    containerRef,\n    isScrolling,\n    setSnapTargetRef,\n    trackSize,\n    snapScrollPositions,\n    isInitialized,\n    isClosing,\n  } = useScrollSnap({\n    direction,\n    snapPoints,\n    activeSnapPointIndex,\n    onSnapPointChange: handleSnapPointChange,\n    onDismiss: handleDismiss,\n    dismissible,\n    contentSize,\n    open,\n    onScrollProgress: handleScrollProgress,\n    onSnapProgress: handleSnapProgress,\n    onImmediateClose: handleImmediateClose,\n    isAnimating,\n    onScrollingChange: setIsDragging,\n  });\n\n  React.useLayoutEffect(() => {\n    parentNesting?.setNestedDragging(isDragging);\n  }, [isDragging, parentNesting]);\n\n  React.useLayoutEffect(() => {\n    if (!open) {\n      parentNesting?.clearNestedDragProgress();\n      parentNesting?.setNestedDragging(false);\n    }\n  }, [open, parentNesting]);\n\n  const floatingMargin = variant === \"floating\" ? 16 : 0;\n  const { mergedRef, popupRef } = useContentMeasurement(\n    isVertical,\n    floatingMargin,\n    setContentSize,\n  );\n\n  const nestingValue = React.useMemo<DrawerNestingContextValue>(\n    () => ({\n      setNestedDragProgress(progress: number) {\n        popupRef.current?.style.setProperty(\n          \"--nested-drag-progress\",\n          String(progress),\n        );\n      },\n      setNestedDragging(dragging: boolean) {\n        if (!popupRef.current) return;\n        if (dragging) {\n          popupRef.current.dataset.nestedDragging = \"\";\n        } else {\n          delete popupRef.current.dataset.nestedDragging;\n        }\n      },\n      clearNestedDragProgress() {\n        popupRef.current?.style.removeProperty(\"--nested-drag-progress\");\n      },\n    }),\n    [popupRef],\n  );\n\n  const {\n    viewportStyle,\n    popupStyle,\n    useScrollDrivenAnimation,\n    targetBackdropOpacity,\n  } = useDrawerContentStyles({\n    direction,\n    variant,\n    activeSnapPoint,\n    snapPoints,\n    contentSize,\n    isVertical,\n    isInitialized,\n    isAnimating,\n    immediateClose,\n    dismissible,\n    snapProgress,\n    repositionInputs,\n    keyboardHeight,\n    visualViewportHeight,\n  });\n\n  return (\n    <DrawerNestingContext.Provider value={nestingValue}>\n      <div\n        data-slot=\"drawer-timeline-scope\"\n        style={\n          supportsScrollTimeline\n            ? ({ timelineScope: \"--drawer-panel\" } as React.CSSProperties)\n            : undefined\n        }\n      >\n        {modal === true && (\n          <BaseDialog.Backdrop\n            data-slot=\"drawer-overlay\"\n            className={cn(\n              \"absolute inset-0 z-50 bg-black/32 backdrop-blur-sm\",\n              \"[transform:translateZ(0)] will-change-[opacity]\",\n              isClosing ? \"pointer-events-none\" : \"pointer-events-auto\",\n              \"touch-none\",\n              immediateClose || (isDragging && !isAnimating)\n                ? \"transition-none\"\n                : \"transition-opacity duration-300\",\n              \"[&[data-starting-style]]:opacity-0!\",\n              // Exit animation overrides scroll-driven animation — transitions can't interpolate from animation-held values.\n              \"data-ending-style:animate-[drawer-backdrop-exit_300ms_cubic-bezier(.32,.72,0,1)_forwards]\",\n              isInitialized && !isAnimating && dismissible && dragProgress < 1\n                ? useScrollDrivenAnimation\n                  ? backdropAnimationStyles[direction]\n                  : `opacity-(--drawer-backdrop-dynamic-opacity)`\n                : `opacity-(--drawer-backdrop-static-opacity)`,\n            )}\n            style={\n              {\n                \"--drawer-backdrop-dynamic-opacity\": 1 - dragProgress,\n                \"--drawer-backdrop-static-opacity\": targetBackdropOpacity,\n              } as React.CSSProperties\n            }\n          />\n        )}\n\n        <BaseDialog.Viewport\n          ref={containerRef}\n          data-slot=\"drawer-viewport\"\n          data-direction={direction}\n          data-scrolling={isScrolling || undefined}\n          data-keyboard-visible={\n            direction === \"bottom\" && repositionInputs && isKeyboardVisible\n              ? \"true\"\n              : undefined\n          }\n          className={cn(\n            \"group/drawer\",\n            \"fixed inset-x-0 z-50 outline-hidden\",\n            // Bottom: -60px top buffer prevents URL bar collapse; non-modal uses lvh\n            direction === \"bottom\" &&\n              (modal === true\n                ? \"top-[-60px] bottom-[env(keyboard-inset-height,var(--keyboard-height,0))]\"\n                : \"top-auto! bottom-[env(keyboard-inset-height,var(--keyboard-height,0))] h-lvh\"),\n            direction === \"top\" && \"top-0! bottom-[-60px]!\",\n            !isVertical &&\n              (modal === true\n                ? \"top-0! bottom-0! h-dvh\"\n                : \"top-0! bottom-0 h-lvh\"),\n            isAnimating || isClosing || modal !== true\n              ? \"pointer-events-none\"\n              : \"pointer-events-auto\",\n            \"bg-transparent opacity-100! [&[data-ending-style]]:opacity-100! [&[data-starting-style]]:opacity-100!\",\n            \"[scrollbar-width:none]! [&::-webkit-scrollbar]:hidden!\",\n            isAnimating || isClosing\n              ? \"touch-none overflow-hidden\"\n              : isVertical\n                ? \"touch-pan-y overflow-x-hidden overflow-y-auto overscroll-y-none\"\n                : \"touch-pan-x overflow-x-auto overflow-y-hidden overscroll-x-none\",\n            isVertical ? \"touch-pan-y\" : \"touch-pan-x\",\n            \"motion-reduce:[scroll-behavior:auto]\",\n            \"has-data-nested-dialog-open:touch-none has-data-nested-dialog-open:overflow-hidden\",\n          )}\n          style={viewportStyle}\n        >\n          <div\n            data-slot=\"drawer-track\"\n            className={drawerTrackVariants({ direction })}\n            style={\n              {\n                [isVertical ? \"height\" : \"width\"]: `${trackSize}px`,\n                \"--content-size\": `${contentSize ?? 0}px`,\n                \"--dismiss-buffer\": dismissible\n                  ? `${(contentSize ?? 0) * 0.3}px`\n                  : \"0px\",\n              } as React.CSSProperties\n            }\n          >\n            {/* Snap targets with JS-calculated positions (CSS calc() has issues on iOS Safari) */}\n            {snapScrollPositions.map((position, index) => (\n              <div\n                key={index}\n                ref={(el) => setSnapTargetRef(index, el)}\n                data-slot=\"drawer-snap-target\"\n                data-snap-index={index}\n                className={cn(\n                  \"pointer-events-none absolute\",\n                  isVertical ? \"inset-x-0 h-px\" : \"inset-y-0 w-px\",\n                )}\n                style={\n                  {\n                    [isVertical ? \"top\" : \"left\"]: `${position}px`,\n                    scrollSnapAlign: \"start\",\n                    scrollSnapStop: sequentialSnap ? \"always\" : undefined,\n                    ...(supportsScrollState && {\n                      containerType: \"scroll-state\",\n                    }),\n                  } as React.CSSProperties\n                }\n                aria-hidden=\"true\"\n              />\n            ))}\n\n            <BaseDialog.Popup\n              ref={mergedRef}\n              data-slot=\"drawer-content\"\n              data-footer-variant={footerVariant}\n              data-level={level}\n              initialFocus={initialFocus ?? popupRef}\n              finalFocus={finalFocus}\n              className={cn(\n                drawerContentVariants({ variant, direction }),\n                // Floating: full 4-edge rim. Flush (`default`): single-edge rim on the\n                // inner-facing side only — other edges would show a 1px line at the viewport boundary.\n                variant === \"floating\"\n                  ? elevatedSurface(level, shadowLevel)\n                  : flushSurface(level, INNER_EDGE_FROM_ATTACH_SIDE[direction]),\n                open && !isInitialized && \"opacity-0\",\n                isAnimating || isClosing\n                  ? \"pointer-events-none\"\n                  : \"pointer-events-auto\",\n                immediateClose && \"transition-none\",\n                \"data-nested-dialog-open:overflow-hidden\",\n                // Safari iOS touch fix (WebKit bug #183870): the popup needs a sliver of\n                // cross-axis scroll so iOS engages its touch handler. The overflow itself\n                // is supplied by the shim <span> below, keeping the rim's ::after to itself.\n                modal !== true && [\n                  \"[@supports(-webkit-touch-callout:none)]:relative [@supports(-webkit-touch-callout:none)]:[scrollbar-width:none]\",\n                  isVertical\n                    ? \"[@supports(-webkit-touch-callout:none)]:overflow-x-scroll [@supports(-webkit-touch-callout:none)]:overscroll-x-none\"\n                    : \"[@supports(-webkit-touch-callout:none)]:overflow-y-scroll [@supports(-webkit-touch-callout:none)]:overscroll-y-none\",\n                ],\n                className,\n              )}\n              style={popupStyle}\n              {...props}\n            >\n              {/* Safari shim: supplies the 1px cross-axis scroll overflow (see the WebKit\n                  fix above) as a child element so the elevation rim keeps the popup's\n                  ::after to itself. Inert (display:none) on every engine except iOS Safari. */}\n              {modal !== true && (\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"hidden [@supports(-webkit-touch-callout:none)]:pointer-events-none [@supports(-webkit-touch-callout:none)]:absolute [@supports(-webkit-touch-callout:none)]:inset-0 [@supports(-webkit-touch-callout:none)]:block\",\n                    isVertical\n                      ? \"[@supports(-webkit-touch-callout:none)]:w-[calc(100%+0.5px)]\"\n                      : \"[@supports(-webkit-touch-callout:none)]:h-[calc(100%+1px)]\",\n                  )}\n                />\n              )}\n              {children}\n              {showCloseButton && (\n                <DrawerClose\n                  aria-label=\"Close\"\n                  className=\"absolute end-2 top-2\"\n                  render={<Button size=\"icon_sm\" variant=\"ghost\" />}\n                >\n                  <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />\n                </DrawerClose>\n              )}\n            </BaseDialog.Popup>\n          </div>\n\n          {SafariNavColorDetectors}\n        </BaseDialog.Viewport>\n      </div>\n    </DrawerNestingContext.Provider>\n  );\n}\n\ninterface DrawerHandleProps extends Omit<\n  useRender.ComponentProps<\"button\">,\n  \"children\"\n> {\n  hidden?: boolean;\n  preventClose?: boolean;\n}\n\nfunction DrawerHandle({\n  className,\n  hidden,\n  preventClose = false,\n  onClick,\n  render,\n  ...props\n}: DrawerHandleProps) {\n  const { isVertical, direction } = useDrawerConfig();\n  const { onOpenChange, isAnimating } = useDrawerControl();\n\n  const isHorizontalDrawer = direction === \"left\" || direction === \"right\";\n\n  const handleClick = React.useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      if (isAnimating || preventClose) return;\n      onOpenChange(false);\n    },\n    [onClick, onOpenChange, isAnimating, preventClose],\n  );\n\n  const defaultProps = {\n    \"data-slot\": \"drawer-handle\" as const,\n    type: \"button\" as const,\n    \"aria-label\": preventClose ? \"Drawer handle\" : \"Close drawer\",\n    onClick: handleClick,\n    className: cn(\n      \"appearance-none border-0 bg-transparent p-0\",\n      \"focus-visible:outline-ring/50 rounded-sm outline-0 outline-offset-0 outline-transparent outline-solid focus-visible:outline-2 focus-visible:outline-offset-2\",\n      \"bg-muted-foreground/30 shrink-0 cursor-pointer rounded-full\",\n      isVertical ? \"mx-auto my-3 h-1 w-12\" : \"mx-3 my-auto h-12 w-1\",\n      \"hover:bg-muted-foreground/50 transition-[color,opacity]\",\n      // Fade out when nested drawer opens, fade back when nested drawer is being dragged\n      // \"in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100\",\n      className,\n    ),\n  };\n\n  const element = useRender({\n    defaultTagName: \"button\",\n    render,\n    props: mergeProps<\"button\">(defaultProps, props),\n  });\n\n  if (hidden || isHorizontalDrawer) return null;\n\n  return element;\n}\n\n// Padding rules use ancestor `:has()` on `[data-slot=drawer-content]` instead of\n// adjacent-sibling selectors so consumers can wrap slots in forms, Suspense, etc.\n// without breaking padding, and so a DrawerHandle between slots doesn't silently\n// drop the header's padding (sibling selectors would miss it).\nfunction DrawerHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-header\"\n      className={cn(\n        \"flex flex-col gap-2 px-6 pt-6 pb-3\",\n        \"in-data-[direction=bottom]:in-[[data-slot=drawer-content]:has([data-slot=drawer-handle])]:pt-1\",\n        // Header alone (with or without footer, no body): expand bottom padding.\n        \"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-body])):has([data-slot=drawer-footer])]:pb-6\",\n        \"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-body])):not(:has([data-slot=drawer-footer]))]:pb-6\",\n        // \"transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DrawerFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-footer\"\n      className={cn(\n        // z-1 above DrawerBody(z-0); translateZ(0) is Safari-only GPU promotion to fix\n        // sticky/transform compositing flash during enter animation.\n        \"z-1 mt-auto flex flex-col-reverse gap-2 bg-(--popup-surface,var(--popover)) px-6 pt-4 pb-6 sm:flex-row sm:justify-end\",\n        \"[@supports(-webkit-touch-callout:none)]:transform-[translateZ(0)]\",\n        \"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-header])):not(:has([data-slot=drawer-body]))]:pt-6\",\n        \"not-in-data-[footer-variant=inset]:in-[[data-slot=drawer-content]:has([data-slot=drawer-body])]:pt-3\",\n        \"in-data-[footer-variant=inset]:border-border in-data-[footer-variant=inset]:bg-muted in-data-[footer-variant=inset]:border-t in-data-[footer-variant=inset]:pt-4 in-data-[footer-variant=inset]:pb-4\",\n        // \"transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DrawerTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseDialog.Title>) {\n  return (\n    <BaseDialog.Title\n      data-slot=\"drawer-title\"\n      className={cn(\n        \"text-foreground text-lg leading-none font-semibold tracking-tight text-balance\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DrawerDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseDialog.Description>) {\n  return (\n    <BaseDialog.Description\n      data-slot=\"drawer-description\"\n      className={cn(\"text-muted-foreground text-sm text-pretty\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DrawerBody({\n  className,\n  nativeScroll = false,\n  fadeEdges = false,\n  scrollbarGutter = false,\n  persistScrollbar,\n  hideScrollbar,\n  scrollLock = false,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  nativeScroll?: boolean;\n  /** Disable scrolling when the drawer is not at its last (full) snap point */\n  scrollLock?: boolean;\n} & Pick<\n    ScrollAreaProps,\n    \"fadeEdges\" | \"scrollbarGutter\" | \"persistScrollbar\" | \"hideScrollbar\"\n  >) {\n  const { isVertical, snapPoints } = useDrawerConfig();\n  const { activeSnapPoint } = useDrawerControl();\n\n  const isAtFullSnap = activeSnapPoint === snapPoints[snapPoints.length - 1];\n\n  return (\n    <div\n      data-slot=\"drawer-body\"\n      className={cn(\n        // z-0: stays below sticky footer during iOS Safari enter animation.\n        \"relative z-0 flex min-h-0 flex-1 flex-col overflow-hidden\",\n        \"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-header]))]:pt-5\",\n        \"in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-footer]))]:pb-5\",\n        // Inset footer: still pad body bottom so content doesn't crowd the footer's top border.\n        \"in-data-[footer-variant=inset]:in-[[data-slot=drawer-content]:has([data-slot=drawer-footer])]:pb-5\",\n        // \"transition-opacity in-data-nested-dialog-open:opacity-0 in-data-nested-dragging:opacity-100\",\n      )}\n    >\n      <ScrollArea\n        className=\"flex-1\"\n        fadeEdges={fadeEdges}\n        scrollbarGutter={scrollbarGutter}\n        persistScrollbar={persistScrollbar}\n        hideScrollbar={hideScrollbar}\n        nativeScroll={nativeScroll}\n        overscrollBehavior=\"auto\"\n        viewportClassName={cn(\n          isVertical ? \"touch-pan-y\" : \"touch-pan-x\",\n          scrollLock && !isAtFullSnap && \"!overflow-hidden\",\n        )}\n      >\n        <div className={cn(\"px-6 py-1\", className)} {...props}>\n          {children}\n        </div>\n      </ScrollArea>\n    </div>\n  );\n}\n\nexport {\n  Drawer,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerPortal,\n  DrawerContent,\n  DrawerHandle,\n  DrawerHeader,\n  DrawerFooter,\n  DrawerTitle,\n  DrawerDescription,\n  DrawerBody,\n  useDrawer,\n  useDrawerConfig,\n  useDrawerAnimation,\n  useDrawerScroll,\n  useDrawerControl,\n  createDrawerHandle,\n};\n\nexport type { DrawerRenderProps, DrawerVariant };\n\nexport { supportsScrollTimeline, supportsScrollState };\n",
      "type": "registry:ui",
      "target": "components/ui/cubby-ui/drawer/drawer.tsx"
    },
    {
      "path": "registry/default/drawer/lib/drawer-utils.ts",
      "content": "/* -------------------------------------------------------------------------------------------------\n * Types\n * -------------------------------------------------------------------------------------------------*/\n\nexport type DrawerDirection = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n/**\n * Snap point value:\n * - number (0-1): percentage of drawer visible (0 = closed, 1 = fully open)\n * - `${number}px`: fixed pixel height visible (e.g., \"200px\")\n */\nexport type SnapPoint = number | `${number}px`;\n\n/**\n * Direction configuration for drawer behavior.\n * Eliminates repeated conditionals throughout the codebase.\n */\nexport const DIRECTION_CONFIG = {\n  top: { isVertical: true, isInverted: true },\n  bottom: { isVertical: true, isInverted: false },\n  left: { isVertical: false, isInverted: true },\n  right: { isVertical: false, isInverted: false },\n} as const;\n\nexport type DirectionConfig = (typeof DIRECTION_CONFIG)[DrawerDirection];\n\n/**\n * Scroll geometry for drawer positioning calculations.\n */\nexport interface ScrollGeometry {\n  /** Total track size (viewport + content + dismiss buffer) */\n  trackSize: number;\n  /** Buffer space for dismiss gesture */\n  dismissBuffer: number;\n  /** Maximum scroll position */\n  maxScroll: number;\n  /** Whether scroll direction is inverted (top/left) */\n  isInverted: boolean;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Browser Support Detection\n * -------------------------------------------------------------------------------------------------*/\n\nexport const supportsScrollEnd =\n  typeof window !== \"undefined\" && \"onscrollend\" in window;\n\n/**\n * Feature detection for scroll-driven animations (animation-timeline: scroll())\n * Chrome 115+, Safari 26+ (future), Firefox flag-only\n */\nexport const supportsScrollTimeline =\n  typeof CSS !== \"undefined\" &&\n  CSS.supports(\"animation-timeline\", \"scroll()\") &&\n  CSS.supports(\"timeline-scope\", \"--test\");\n\n/**\n * Feature detection for scroll snap events (scrollsnapchange, scrollsnapchanging)\n * Chrome 129+ only\n */\nexport const supportsScrollSnapChange =\n  typeof window !== \"undefined\" && \"onscrollsnapchange\" in window;\n\n/**\n * Feature detection for CSS scroll-state() container queries\n * Chrome 133+ only\n */\nexport const supportsScrollState =\n  typeof CSS !== \"undefined\" && CSS.supports(\"container-type\", \"scroll-state\");\n\n/* -------------------------------------------------------------------------------------------------\n * Utility Functions\n * -------------------------------------------------------------------------------------------------*/\n\n/**\n * Parse a pixel value string (e.g., \"200px\") and return the number\n */\nexport function parsePixelValue(value: string): number | null {\n  const match = value.match(/^(\\d+(?:\\.\\d+)?)px$/);\n  return match ? parseFloat(match[1]) : null;\n}\n\n/**\n * Convert a snap point to a ratio (0-1).\n * For percentage snap points, returns the value directly.\n * For pixel snap points, divides by contentSize.\n */\nexport function snapPointToRatio(\n  snapPoint: SnapPoint,\n  contentSize: number,\n): number {\n  if (typeof snapPoint === \"number\") {\n    return snapPoint;\n  }\n  const pixels = parsePixelValue(snapPoint);\n  return pixels != null ? pixels / contentSize : 1;\n}\n\n/**\n * Find the index of a snap point value in the array\n * Returns the last index if value is null or not found\n */\nexport function findSnapPointIndex(\n  snapPoints: SnapPoint[],\n  value: SnapPoint | null,\n): number {\n  if (value === null) return snapPoints.length - 1;\n  const index = snapPoints.findIndex((sp) => sp === value);\n  return index === -1 ? snapPoints.length - 1 : index;\n}\n\n/**\n * Get the snap point value at a given index (clamped to valid range)\n */\nexport function getSnapPointValue(\n  snapPoints: SnapPoint[],\n  index: number,\n): SnapPoint {\n  return snapPoints[Math.max(0, Math.min(index, snapPoints.length - 1))];\n}\n\n/**\n * Wait for scroll to end on an element\n */\nexport function waitForScrollEnd(element: HTMLElement): Promise<void> {\n  return new Promise((resolve) => {\n    if (supportsScrollEnd) {\n      element.addEventListener(\"scrollend\", () => resolve(), { once: true });\n    } else {\n      // Fallback: debounced scroll detection\n      let timeout: ReturnType<typeof setTimeout>;\n      const handler = () => {\n        clearTimeout(timeout);\n        timeout = setTimeout(() => {\n          element.removeEventListener(\"scroll\", handler);\n          resolve();\n        }, 0);\n      };\n      element.addEventListener(\"scroll\", handler, { passive: true });\n    }\n  });\n}\n\n/**\n * Check if user prefers reduced motion\n */\nexport function prefersReducedMotion(): boolean {\n  if (typeof window === \"undefined\") return false;\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Scroll Geometry Calculations\n * -------------------------------------------------------------------------------------------------*/\n\n/**\n * Calculate scroll geometry for drawer positioning.\n * These values determine track size and scroll positions for snap points.\n */\nexport function calculateScrollGeometry(\n  viewportSize: number,\n  contentSize: number,\n  dismissible: boolean,\n  isInverted: boolean,\n): ScrollGeometry {\n  // Caller is responsible for ensuring contentSize is valid (> 0)\n  const dismissBuffer = dismissible ? contentSize * 0.3 : 0;\n  const trackSize = viewportSize + contentSize + dismissBuffer;\n  const maxScroll = trackSize - viewportSize;\n\n  return {\n    trackSize,\n    dismissBuffer,\n    maxScroll,\n    isInverted,\n  };\n}\n\n/**\n * Calculate scroll positions for each snap point.\n * Returns an array where index 0 is dismiss position (if dismissible),\n * followed by positions for each snap point.\n */\nexport function calculateSnapScrollPositions(\n  snapPoints: SnapPoint[],\n  geometry: ScrollGeometry,\n  dismissible: boolean,\n  contentSize: number,\n): number[] {\n  const { maxScroll, dismissBuffer, isInverted } = geometry;\n  const positions: number[] = [];\n\n  // Add dismiss position first if dismissible\n  if (dismissible) {\n    positions.push(isInverted ? maxScroll : 0);\n  }\n\n  // Calculate positions for each snap point\n  // Note: contentSize here is effectiveSize (with fallback applied)\n  for (const snapPoint of snapPoints) {\n    const visibleRatio =\n      typeof snapPoint === \"string\"\n        ? (parsePixelValue(snapPoint) ?? contentSize) / contentSize\n        : snapPoint;\n\n    // Calculate scroll position based on direction\n    // Formula: dismissBuffer + visibleRatio * contentSize (non-inverted)\n    // Or: maxScroll - dismissBuffer - visibleRatio * contentSize (inverted)\n    const scrollPos = isInverted\n      ? maxScroll - dismissBuffer - visibleRatio * contentSize\n      : dismissBuffer + visibleRatio * contentSize;\n\n    positions.push(Math.min(maxScroll, Math.max(0, scrollPos)));\n  }\n\n  return positions;\n}\n\n/**\n * Calculate progress from scroll position (0 = fully open, 1 = fully closed).\n * Used for backdrop opacity animation.\n */\nexport function calculateScrollProgress(\n  scrollPos: number,\n  geometry: ScrollGeometry,\n  contentSize: number,\n): number {\n  const { dismissBuffer, isInverted } = geometry;\n\n  let progress: number;\n  if (isInverted) {\n    // Top/Left: scroll 0 = open (0), maxScroll = closed (1)\n    // openScrollPos simplifies to 0 (maxScroll - dismissBuffer - contentSize = 0)\n    progress = scrollPos / contentSize;\n  } else {\n    // Bottom/Right: 0 = closed (1), maxScroll = open (0)\n    progress = 1 - (scrollPos - dismissBuffer) / contentSize;\n  }\n\n  return Math.min(1, Math.max(0, progress));\n}\n\n/**\n * Calculate snap progress from scroll position (0 = first snap, 1 = last snap).\n * Used for crossfade effects between snap points.\n */\nexport function calculateSnapProgress(\n  scrollPos: number,\n  snapScrollPositions: number[],\n  dismissible: boolean,\n): number {\n  const firstSnapIndex = dismissible ? 1 : 0;\n  const lastSnapIndex = snapScrollPositions.length - 1;\n\n  // Handle edge cases\n  if (firstSnapIndex >= lastSnapIndex) return 0;\n\n  const firstSnapPos = snapScrollPositions[firstSnapIndex];\n  const lastSnapPos = snapScrollPositions[lastSnapIndex];\n\n  if (firstSnapPos === lastSnapPos) return 0;\n\n  const progress =\n    (scrollPos - firstSnapPos) / (lastSnapPos - firstSnapPos);\n  return Math.min(1, Math.max(0, progress));\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/drawer/lib/drawer-utils.ts"
    },
    {
      "path": "registry/default/drawer/hooks/use-scroll-snap.ts",
      "content": "import * as React from \"react\";\n\nimport type {\n  DrawerDirection,\n  SnapPoint,\n  ScrollGeometry,\n} from \"../lib/drawer-utils\";\nimport {\n  DIRECTION_CONFIG,\n  supportsScrollEnd,\n  supportsScrollTimeline,\n  supportsScrollState,\n  supportsScrollSnapChange,\n  prefersReducedMotion,\n  waitForScrollEnd,\n  calculateScrollGeometry,\n  calculateSnapScrollPositions,\n  calculateScrollProgress,\n  calculateSnapProgress,\n} from \"../lib/drawer-utils\";\n\nexport interface UseScrollSnapOptions {\n  direction: DrawerDirection;\n  snapPoints: SnapPoint[];\n  activeSnapPointIndex: number;\n  onSnapPointChange: (index: number) => void;\n  onDismiss: () => void;\n  dismissible: boolean;\n  contentSize: number | null;\n  open: boolean;\n  /** 0 = open, 1 = closed */\n  onScrollProgress?: (progress: number) => void;\n  /** 0 = first snap, 1 = last snap */\n  onSnapProgress?: (progress: number) => void;\n  onImmediateClose?: () => void;\n  isAnimating?: boolean;\n  onScrollingChange?: (isScrolling: boolean) => void;\n}\n\nexport interface UseScrollSnapReturn {\n  containerRef: React.RefObject<HTMLDivElement | null>;\n  isScrolling: boolean;\n  setSnapTargetRef: (index: number, el: HTMLDivElement | null) => void;\n  trackSize: number;\n  snapScrollPositions: number[];\n  isInitialized: boolean;\n  isClosing: boolean;\n}\n\ninterface ScrollControlState {\n  isProgrammatic: boolean;\n  lastDetectedSnapIndex: number;\n  isFromDetection: boolean;\n}\n\ninterface InteractionState {\n  isClosing: boolean;\n  isPointerDown: boolean;\n  prevScrollPos: number | null;\n  /** Firefox: click stops momentum scroll, need to track if scrollend fired while pointer down */\n  scrollEndedWhilePointerDown: boolean;\n}\n\ninterface InitState {\n  hasInitialized: boolean;\n  retryTimeout: ReturnType<typeof setTimeout> | null;\n  rafId: number | null;\n  rafLastPos: number | null;\n  rafStableCount: number;\n}\n\nexport { supportsScrollTimeline, supportsScrollState };\n\nexport function useScrollSnap(\n  options: UseScrollSnapOptions,\n): UseScrollSnapReturn {\n  const {\n    direction,\n    snapPoints,\n    activeSnapPointIndex,\n    dismissible,\n    contentSize,\n    open,\n    onScrollingChange,\n  } = options;\n\n  // Get direction config (replaces repeated conditionals)\n  const { isVertical, isInverted } = DIRECTION_CONFIG[direction];\n\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const snapTargetRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n\n  const scrollControlRef = React.useRef<ScrollControlState>({\n    isProgrammatic: false,\n    lastDetectedSnapIndex: activeSnapPointIndex,\n    isFromDetection: false,\n  });\n\n  const checkScrollStabilityRef = React.useRef<() => void>(() => {});\n\n  const interactionRef = React.useRef<InteractionState>({\n    isClosing: false,\n    isPointerDown: false,\n    prevScrollPos: null,\n    scrollEndedWhilePointerDown: false,\n  });\n\n  const initRef = React.useRef<InitState>({\n    hasInitialized: false,\n    retryTimeout: null,\n    rafId: null,\n    rafLastPos: null,\n    rafStableCount: 0,\n  });\n\n  const optionsRef = React.useRef(options);\n  React.useLayoutEffect(() => {\n    optionsRef.current = options;\n  });\n\n  const [isScrolling, setIsScrolling] = React.useState(false);\n  const [isClosing, setIsClosingState] = React.useState(false);\n  const [isInitialized, setIsInitialized] = React.useState(false);\n  const [viewportSize, setViewportSize] = React.useState(() => {\n    if (typeof window === \"undefined\") return 800;\n    return isVertical ? window.innerHeight : window.innerWidth;\n  });\n\n  const setIsClosing = React.useCallback((value: boolean) => {\n    interactionRef.current.isClosing = value;\n    setIsClosingState(value);\n  }, []);\n\n  const updateIsScrolling = React.useCallback(\n    (value: boolean) => {\n      setIsScrolling(value);\n      onScrollingChange?.(value);\n    },\n    [onScrollingChange],\n  );\n\n  // Fallback size until measured (prevents iOS Safari timing issues)\n  const effectiveSize = contentSize ?? viewportSize * 0.9;\n\n  const geometry = React.useMemo<ScrollGeometry>(\n    () =>\n      calculateScrollGeometry(\n        viewportSize,\n        effectiveSize,\n        dismissible,\n        isInverted,\n      ),\n    [viewportSize, effectiveSize, dismissible, isInverted],\n  );\n\n  const snapScrollPositions = React.useMemo(\n    () =>\n      calculateSnapScrollPositions(\n        snapPoints,\n        geometry,\n        dismissible,\n        effectiveSize,\n      ),\n    [snapPoints, geometry, dismissible, effectiveSize],\n  );\n\n  const getScrollPositionForSnapPoint = React.useCallback(\n    (index: number): number => {\n      const adjustedIndex = dismissible ? index + 1 : index;\n      return snapScrollPositions[adjustedIndex] ?? 0;\n    },\n    [snapScrollPositions, dismissible],\n  );\n\n  const findNearestSnapIndex = React.useCallback(\n    (scrollPos: number): { index: number; isDismiss: boolean } => {\n      let closestIndex = 0;\n      let closestDistance = Math.abs(\n        scrollPos - (snapScrollPositions[0] ?? 0),\n      );\n\n      for (let i = 1; i < snapScrollPositions.length; i++) {\n        const distance = Math.abs(scrollPos - snapScrollPositions[i]);\n        if (distance < closestDistance) {\n          closestDistance = distance;\n          closestIndex = i;\n        }\n      }\n\n      if (dismissible && closestIndex === 0) {\n        return { index: 0, isDismiss: true };\n      }\n\n      const snapIndex = dismissible ? closestIndex - 1 : closestIndex;\n      return { index: Math.max(0, snapIndex), isDismiss: false };\n    },\n    [snapScrollPositions, dismissible],\n  );\n\n  const detectAndNotifySnapChange = React.useCallback(\n    (scrollPos: number) => {\n      if (\n        scrollControlRef.current.isProgrammatic ||\n        interactionRef.current.isClosing\n      ) {\n        return;\n      }\n\n      const { index, isDismiss } = findNearestSnapIndex(scrollPos);\n      if (\n        !isDismiss &&\n        index !== scrollControlRef.current.lastDetectedSnapIndex\n      ) {\n        scrollControlRef.current.lastDetectedSnapIndex = index;\n        scrollControlRef.current.isFromDetection = true;\n        optionsRef.current.onSnapPointChange(index);\n      }\n    },\n    [findNearestSnapIndex],\n  );\n\n  const scrollToSnapPoint = React.useCallback(\n    (index: number, behavior: ScrollBehavior = \"smooth\") => {\n      const container = containerRef.current;\n      if (!container) return;\n\n      const scrollPos = getScrollPositionForSnapPoint(index);\n      const actualBehavior = prefersReducedMotion() ? \"auto\" : behavior;\n\n      scrollControlRef.current.isProgrammatic = true;\n      container.scrollTo({\n        [isVertical ? \"top\" : \"left\"]: scrollPos,\n        behavior: actualBehavior,\n      });\n\n      if (actualBehavior === \"auto\") {\n        scrollControlRef.current.isProgrammatic = false;\n      } else {\n        waitForScrollEnd(container).then(() => {\n          scrollControlRef.current.isProgrammatic = false;\n        });\n      }\n    },\n    [getScrollPositionForSnapPoint, isVertical],\n  );\n\n  const triggerImmediateDismiss = React.useCallback(() => {\n    if (interactionRef.current.isClosing) return;\n\n    const container = containerRef.current;\n    if (container) {\n      container.style.overflow = \"hidden\";\n      container.style.pointerEvents = \"none\";\n      container.style.touchAction = \"none\";\n    }\n\n    setIsClosing(true);\n    scrollControlRef.current.isProgrammatic = true;\n\n    optionsRef.current.onImmediateClose?.();\n    optionsRef.current.onDismiss();\n  }, [setIsClosing]);\n\n  // Fallback for browsers without scrollend event\n  const checkScrollStability = React.useCallback(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const { isVertical: isVert } =\n      DIRECTION_CONFIG[optionsRef.current.direction];\n    const currentPos = isVert ? container.scrollTop : container.scrollLeft;\n    const { rafLastPos } = initRef.current;\n\n    if (rafLastPos !== null && Math.abs(currentPos - rafLastPos) < 0.5) {\n      initRef.current.rafStableCount++;\n      // 3 stable frames (~50ms) = scroll ended (if not touching)\n      if (\n        initRef.current.rafStableCount >= 3 &&\n        !interactionRef.current.isPointerDown\n      ) {\n        updateIsScrolling(false);\n        detectAndNotifySnapChange(currentPos);\n\n        initRef.current.rafId = null;\n        initRef.current.rafLastPos = null;\n        initRef.current.rafStableCount = 0;\n        return;\n      }\n    } else {\n      initRef.current.rafStableCount = 0;\n    }\n\n    initRef.current.rafLastPos = currentPos;\n    initRef.current.rafId = requestAnimationFrame(\n      checkScrollStabilityRef.current,\n    );\n  }, [updateIsScrolling, detectAndNotifySnapChange]);\n\n  React.useLayoutEffect(() => {\n    checkScrollStabilityRef.current = checkScrollStability;\n  });\n\n  const startScrollStabilityCheck = React.useCallback(() => {\n    if (initRef.current.rafId === null) {\n      initRef.current.rafLastPos = null;\n      initRef.current.rafStableCount = 0;\n      initRef.current.rafId = requestAnimationFrame(checkScrollStability);\n    }\n  }, [checkScrollStability]);\n\n  const handleScroll = React.useCallback(() => {\n    if (!initRef.current.hasInitialized) return;\n\n    const container = containerRef.current;\n    if (!container) return;\n\n    const { isVertical: isVert } =\n      DIRECTION_CONFIG[optionsRef.current.direction];\n    const scrollPos = isVert ? container.scrollTop : container.scrollLeft;\n\n    const progress = calculateScrollProgress(scrollPos, geometry, effectiveSize);\n    optionsRef.current.onScrollProgress?.(progress);\n\n    const snapProg = calculateSnapProgress(\n      scrollPos,\n      snapScrollPositions,\n      optionsRef.current.dismissible,\n    );\n    optionsRef.current.onSnapProgress?.(snapProg);\n\n    // 2px threshold filters scroll-snap micro-adjustments\n    const positionChanged =\n      interactionRef.current.prevScrollPos !== null &&\n      Math.abs(scrollPos - interactionRef.current.prevScrollPos) > 2;\n\n    interactionRef.current.prevScrollPos = scrollPos;\n\n    if (!scrollControlRef.current.isProgrammatic && positionChanged) {\n      updateIsScrolling(true);\n      if (!supportsScrollEnd) {\n        startScrollStabilityCheck();\n      }\n    }\n\n    if (!scrollControlRef.current.isProgrammatic) {\n      if (\n        optionsRef.current.dismissible &&\n        !interactionRef.current.isClosing &&\n        progress >= 1\n      ) {\n        triggerImmediateDismiss();\n      }\n    }\n  }, [\n    effectiveSize,\n    geometry,\n    snapScrollPositions,\n    updateIsScrolling,\n    startScrollStabilityCheck,\n    triggerImmediateDismiss,\n  ]);\n\n  const handleScrollEnd = React.useCallback(() => {\n    // Firefox: clicking stops momentum, fires scrollend while pointer down\n    if (interactionRef.current.isPointerDown) {\n      interactionRef.current.scrollEndedWhilePointerDown = true;\n    } else {\n      updateIsScrolling(false);\n    }\n\n    const container = containerRef.current;\n    if (container) {\n      const { isVertical: isVert } =\n        DIRECTION_CONFIG[optionsRef.current.direction];\n      const scrollPos = isVert ? container.scrollTop : container.scrollLeft;\n      detectAndNotifySnapChange(scrollPos);\n    }\n  }, [updateIsScrolling, detectAndNotifySnapChange]);\n\n  const handleScrollSnapChange = React.useCallback((event: Event) => {\n    if (interactionRef.current.isClosing) return;\n\n    const snapEvent = event as Event & {\n      snapTargetBlock?: Element | null;\n      snapTargetInline?: Element | null;\n    };\n\n    const { isVertical: isVert } =\n      DIRECTION_CONFIG[optionsRef.current.direction];\n    const snapTarget = isVert\n      ? snapEvent.snapTargetBlock\n      : snapEvent.snapTargetInline;\n\n    if (!snapTarget) return;\n\n    const snapIndexAttr = snapTarget.getAttribute(\"data-snap-index\");\n    if (snapIndexAttr === null) return;\n\n    const rawIndex = parseInt(snapIndexAttr, 10);\n    if (isNaN(rawIndex)) return;\n\n    const actualIndex = optionsRef.current.dismissible\n      ? rawIndex - 1\n      : rawIndex;\n    if (actualIndex < 0) return;\n\n    if (actualIndex !== scrollControlRef.current.lastDetectedSnapIndex) {\n      scrollControlRef.current.lastDetectedSnapIndex = actualIndex;\n      scrollControlRef.current.isFromDetection = true;\n      optionsRef.current.onSnapPointChange(actualIndex);\n    }\n  }, []);\n\n  // Touch events only (not mouse) - iOS Safari has quirky pointer event behavior\n  const handleTouchStart = React.useCallback(() => {\n    interactionRef.current.isPointerDown = true;\n  }, []);\n\n  const handleTouchEnd = React.useCallback(() => {\n    interactionRef.current.isPointerDown = false;\n    initRef.current.rafStableCount = 0;\n\n    // Firefox: if scrollend fired while pointer down, start stability check\n    if (interactionRef.current.scrollEndedWhilePointerDown) {\n      interactionRef.current.scrollEndedWhilePointerDown = false;\n      startScrollStabilityCheck();\n    }\n  }, [startScrollStabilityCheck]);\n\n  // Stable handler refs — keeps the event listener effect from re-running\n  // when handler dependencies change (geometry, snapScrollPositions, etc.)\n  const handleScrollRef = React.useRef(handleScroll);\n  const handleScrollEndRef = React.useRef(handleScrollEnd);\n  const handleScrollSnapChangeRef = React.useRef(handleScrollSnapChange);\n  const handleTouchStartRef = React.useRef(handleTouchStart);\n  const handleTouchEndRef = React.useRef(handleTouchEnd);\n  React.useLayoutEffect(() => {\n    handleScrollRef.current = handleScroll;\n    handleScrollEndRef.current = handleScrollEnd;\n    handleScrollSnapChangeRef.current = handleScrollSnapChange;\n    handleTouchStartRef.current = handleTouchStart;\n    handleTouchEndRef.current = handleTouchEnd;\n  });\n\n  // Initialization (DrawerContentInner unmounts on close, giving fresh state)\n  React.useEffect(() => {\n    if (!open) {\n      if (initRef.current.rafId !== null) {\n        cancelAnimationFrame(initRef.current.rafId);\n        initRef.current.rafId = null;\n      }\n      if (initRef.current.retryTimeout) {\n        clearTimeout(initRef.current.retryTimeout);\n        initRef.current.retryTimeout = null;\n      }\n      return;\n    }\n\n    if (contentSize === null) return;\n\n    if (!initRef.current.hasInitialized) {\n      const performInitialScroll = () => {\n        const container = containerRef.current;\n        if (!container) {\n          initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);\n          return;\n        }\n\n        const size = isVertical\n          ? container.clientHeight\n          : container.clientWidth;\n        if (size === 0) {\n          initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);\n          return;\n        }\n\n        initRef.current.hasInitialized = true;\n\n        if (size !== viewportSize) {\n          setViewportSize(size);\n        }\n\n        const targetIndex = dismissible\n          ? activeSnapPointIndex + 1\n          : activeSnapPointIndex;\n        const targetScrollPos = snapScrollPositions[targetIndex] ?? 0;\n\n        scrollControlRef.current.isProgrammatic = true;\n        scrollControlRef.current.lastDetectedSnapIndex = activeSnapPointIndex;\n\n        if (isVertical) {\n          container.scrollTop = targetScrollPos;\n        } else {\n          container.scrollLeft = targetScrollPos;\n        }\n\n        const initialProgress = calculateScrollProgress(\n          targetScrollPos,\n          geometry,\n          effectiveSize,\n        );\n        optionsRef.current.onScrollProgress?.(initialProgress);\n\n        const initialSnapProgress = calculateSnapProgress(\n          targetScrollPos,\n          snapScrollPositions,\n          dismissible,\n        );\n        optionsRef.current.onSnapProgress?.(initialSnapProgress);\n\n        setTimeout(() => {\n          scrollControlRef.current.isProgrammatic = false;\n        }, 0);\n\n        setIsInitialized(true);\n      };\n\n      initRef.current.retryTimeout = setTimeout(performInitialScroll, 0);\n    }\n\n    const currentInit = initRef.current;\n    return () => {\n      if (currentInit.retryTimeout) {\n        clearTimeout(currentInit.retryTimeout);\n        currentInit.retryTimeout = null;\n      }\n    };\n  }, [\n    open,\n    contentSize,\n    effectiveSize,\n    isVertical,\n    viewportSize,\n    dismissible,\n    activeSnapPointIndex,\n    snapScrollPositions,\n    geometry,\n  ]);\n\n  // Sync lastDetectedSnapIndex to prevent false isFromDetection after programmatic scrolls\n  React.useEffect(() => {\n    if (open) {\n      scrollControlRef.current.lastDetectedSnapIndex = activeSnapPointIndex;\n    }\n  }, [open, activeSnapPointIndex]);\n\n  // Handle controlled snap point changes\n  const prevSnapPointRef = React.useRef(activeSnapPointIndex);\n  React.useEffect(() => {\n    if (prevSnapPointRef.current !== activeSnapPointIndex && open) {\n      if (scrollControlRef.current.isFromDetection) {\n        scrollControlRef.current.isFromDetection = false;\n        prevSnapPointRef.current = activeSnapPointIndex;\n        return;\n      }\n\n      const container = containerRef.current;\n      if (container) {\n        const currentScrollPos = isVertical\n          ? container.scrollTop\n          : container.scrollLeft;\n        const targetScrollPos =\n          getScrollPositionForSnapPoint(activeSnapPointIndex);\n\n        if (Math.abs(currentScrollPos - targetScrollPos) > 10) {\n          scrollToSnapPoint(activeSnapPointIndex, \"smooth\");\n        }\n      }\n    }\n    prevSnapPointRef.current = activeSnapPointIndex;\n  }, [\n    activeSnapPointIndex,\n    open,\n    scrollToSnapPoint,\n    isVertical,\n    getScrollPositionForSnapPoint,\n  ]);\n\n  // Re-position when geometry changes (e.g., keyboard appears)\n  const prevTrackSizeRef = React.useRef(0);\n  React.useEffect(() => {\n    if (!open || !initRef.current.hasInitialized) return;\n\n    const { trackSize } = geometry;\n\n    if (\n      prevTrackSizeRef.current !== 0 &&\n      prevTrackSizeRef.current !== trackSize\n    ) {\n      const container = containerRef.current;\n      if (container && !scrollControlRef.current.isProgrammatic) {\n        const targetScrollPos =\n          getScrollPositionForSnapPoint(activeSnapPointIndex);\n        const prevScrollBehavior = container.style.scrollBehavior;\n\n        container.style.scrollBehavior = \"auto\";\n        if (isVertical) {\n          container.scrollTop = targetScrollPos;\n        } else {\n          container.scrollLeft = targetScrollPos;\n        }\n        container.style.scrollBehavior = prevScrollBehavior;\n      }\n    }\n\n    prevTrackSizeRef.current = trackSize;\n  }, [\n    open,\n    geometry,\n    isVertical,\n    activeSnapPointIndex,\n    getScrollPositionForSnapPoint,\n  ]);\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container || !open) return;\n\n    // Stable wrappers dispatch through refs so handler dep changes\n    // don't tear down and re-register all listeners.\n    const onScroll = () => handleScrollRef.current();\n    const onScrollEnd = () => handleScrollEndRef.current();\n    const onScrollSnapChange = (e: Event) =>\n      handleScrollSnapChangeRef.current(e);\n    const onTouchStart = () => handleTouchStartRef.current();\n    const onTouchEnd = () => handleTouchEndRef.current();\n\n    container.addEventListener(\"scroll\", onScroll, { passive: true });\n    container.addEventListener(\"touchstart\", onTouchStart, { passive: true });\n    container.addEventListener(\"touchend\", onTouchEnd, { passive: true });\n    container.addEventListener(\"touchcancel\", onTouchEnd, { passive: true });\n\n    if (supportsScrollEnd) {\n      container.addEventListener(\"scrollend\", onScrollEnd);\n    }\n\n    if (supportsScrollSnapChange) {\n      container.addEventListener(\"scrollsnapchange\", onScrollSnapChange);\n    }\n\n    const updateViewportSize = () => {\n      const size = isVertical ? container.clientHeight : container.clientWidth;\n      setViewportSize(size);\n    };\n    window.addEventListener(\"resize\", updateViewportSize);\n\n    return () => {\n      container.removeEventListener(\"scroll\", onScroll);\n      container.removeEventListener(\"touchstart\", onTouchStart);\n      container.removeEventListener(\"touchend\", onTouchEnd);\n      container.removeEventListener(\"touchcancel\", onTouchEnd);\n\n      if (supportsScrollEnd) {\n        container.removeEventListener(\"scrollend\", onScrollEnd);\n      }\n\n      if (supportsScrollSnapChange) {\n        container.removeEventListener(\"scrollsnapchange\", onScrollSnapChange);\n      }\n\n      window.removeEventListener(\"resize\", updateViewportSize);\n    };\n  }, [open, isVertical]);\n\n  const setSnapTargetRef = React.useCallback(\n    (index: number, el: HTMLDivElement | null) => {\n      snapTargetRefs.current[index] = el;\n    },\n    [],\n  );\n\n  return {\n    containerRef,\n    isScrolling,\n    setSnapTargetRef,\n    trackSize: geometry.trackSize,\n    snapScrollPositions,\n    isInitialized,\n    isClosing,\n  };\n}\n",
      "type": "registry:hook",
      "target": "components/ui/cubby-ui/drawer/hooks/use-scroll-snap.ts"
    },
    {
      "path": "registry/default/drawer/hooks/use-virtual-keyboard.ts",
      "content": "import * as React from \"react\";\n\nexport interface UseVirtualKeyboardOptions {\n  /** Whether keyboard handling is enabled */\n  enabled?: boolean;\n}\n\nexport interface UseVirtualKeyboardReturn {\n  /** Current keyboard height in pixels (0 when closed) */\n  keyboardHeight: number;\n  /** Whether the virtual keyboard is currently visible */\n  isKeyboardVisible: boolean;\n}\n\n/**\n * Helper to check if element is an input that would trigger a virtual keyboard.\n */\nfunction isInput(element: HTMLElement | null): boolean {\n  if (!element) return false;\n  const tagName = element.tagName.toLowerCase();\n  return (\n    tagName === \"input\" ||\n    tagName === \"textarea\" ||\n    tagName === \"select\" ||\n    element.isContentEditable\n  );\n}\n\n/**\n * Detect Firefox mobile which natively handles keyboard repositioning.\n * We skip our custom transform on Firefox to avoid double-repositioning.\n */\nconst isFirefoxMobile =\n  typeof navigator !== \"undefined\" &&\n  /Firefox/i.test(navigator.userAgent) &&\n  /Android|Mobile/i.test(navigator.userAgent);\n\n/**\n * Hook to detect virtual keyboard visibility and height using the Visual Viewport API.\n *\n * On mobile devices, when a virtual keyboard appears, it reduces the visual viewport height.\n * This hook tracks that change so components can adjust their layout accordingly.\n *\n * Simplified approach:\n * - Keyboard is visible if viewport is significantly smaller (>100px) AND an input is focused\n * - No complex toggle state that can get out of sync\n */\nexport function useVirtualKeyboard({\n  enabled = true,\n}: UseVirtualKeyboardOptions = {}): UseVirtualKeyboardReturn {\n  const [keyboardHeight, setKeyboardHeight] = React.useState(0);\n\n  React.useEffect(() => {\n    // Firefox mobile natively handles keyboard repositioning, so we skip\n    // our custom detection to avoid double-repositioning the drawer\n    if (!enabled || typeof window === \"undefined\" || isFirefoxMobile) return;\n\n    // Enable Virtual Keyboard API to get env(keyboard-inset-*) CSS variables\n    // This tells the browser that we'll handle the virtual keyboard geometry ourselves\n    if (\"virtualKeyboard\" in navigator) {\n      (navigator as Navigator & { virtualKeyboard: { overlaysContent: boolean } })\n        .virtualKeyboard.overlaysContent = true;\n    }\n\n    const visualViewport = window.visualViewport;\n    if (!visualViewport) return;\n\n    const handleResize = () => {\n      const focusedElement = document.activeElement as HTMLElement;\n      const isInputFocused = isInput(focusedElement);\n\n      // If no input is focused, keyboard can't be open for our purposes\n      if (!isInputFocused) {\n        setKeyboardHeight(0);\n        return;\n      }\n\n      const visualViewportHeight = visualViewport.height;\n      const totalHeight = window.innerHeight;\n      const diff = totalHeight - visualViewportHeight;\n\n      // Keyboard is visible if viewport is significantly smaller (>100px threshold)\n      // This avoids false positives from address bar changes\n      if (diff > 100) {\n        setKeyboardHeight(diff);\n      } else {\n        setKeyboardHeight(0);\n      }\n    };\n\n    visualViewport.addEventListener(\"resize\", handleResize);\n\n    // Initial check in case keyboard is already open\n    handleResize();\n\n    return () => visualViewport.removeEventListener(\"resize\", handleResize);\n  }, [enabled]);\n\n  return {\n    keyboardHeight,\n    isKeyboardVisible: keyboardHeight > 0,\n  };\n}\n",
      "type": "registry:hook",
      "target": "components/ui/cubby-ui/drawer/hooks/use-virtual-keyboard.ts"
    },
    {
      "path": "registry/default/drawer/hooks/use-visual-viewport-height.ts",
      "content": "import * as React from \"react\";\n\nexport interface UseVisualViewportHeightOptions {\n  /** Whether tracking is enabled */\n  enabled?: boolean;\n}\n\n/**\n * Hook to track the visual viewport height using the Visual Viewport API.\n *\n * On mobile devices, the visual viewport height changes when the URL bar\n * collapses/expands. This hook provides real-time tracking of the actual\n * visible viewport height, which can be used to size elements that need\n * to fill the visible area regardless of URL bar state.\n *\n * @returns The current visual viewport height in pixels, or null if not available\n */\nexport function useVisualViewportHeight({\n  enabled = true,\n}: UseVisualViewportHeightOptions = {}): number | null {\n  const [height, setHeight] = React.useState<number | null>(() => {\n    if (typeof window === \"undefined\") return null;\n    return window.visualViewport?.height ?? null;\n  });\n\n  React.useEffect(() => {\n    if (!enabled || typeof window === \"undefined\") return;\n\n    const visualViewport = window.visualViewport;\n    if (!visualViewport) return;\n\n    const handleResize = () => {\n      setHeight(visualViewport.height);\n    };\n\n    // Set initial value\n    handleResize();\n\n    visualViewport.addEventListener(\"resize\", handleResize);\n    return () => visualViewport.removeEventListener(\"resize\", handleResize);\n  }, [enabled]);\n\n  return height;\n}\n",
      "type": "registry:hook",
      "target": "components/ui/cubby-ui/drawer/hooks/use-visual-viewport-height.ts"
    },
    {
      "path": "registry/default/drawer/drawer.css",
      "content": "/* Scroll-Driven Animations (Chrome 115+, Safari 26+, Firefox flag-only) */\r\n\r\n/**\r\n * Consumers can animate based on snap progress:\r\n * .mini-player { opacity: calc(1 - var(--drawer-snap-progress, 0)); }\r\n */\r\n@property --drawer-snap-progress {\r\n  syntax: \"<number>\";\r\n  inherits: true;\r\n  initial-value: 0;\r\n}\r\n\r\n@keyframes drawer-snap-progress {\r\n  from {\r\n    --drawer-snap-progress: 0;\r\n  }\r\n  to {\r\n    --drawer-snap-progress: 1;\r\n  }\r\n}\r\n\r\n@keyframes drawer-backdrop-fade {\r\n  from {\r\n    opacity: 0;\r\n  }\r\n  to {\r\n    opacity: 1;\r\n  }\r\n}\r\n\r\n/* Overrides scroll-driven animation on exit (transitions can't interpolate from animation values) */\r\n@keyframes drawer-backdrop-exit {\r\n  to {\r\n    opacity: 0;\r\n  }\r\n}\r\n\r\n/* Horizontal drawers: prevent page scroll interference */\r\nhtml:has([data-slot=\"drawer-viewport\"][data-direction=\"left\"]),\r\nhtml:has([data-slot=\"drawer-viewport\"][data-direction=\"right\"]) {\r\n  overscroll-behavior: none;\r\n  scrollbar-width: none;\r\n}\r\n\r\nhtml:has(\r\n    [data-slot=\"drawer-viewport\"][data-direction=\"left\"]\r\n  )::-webkit-scrollbar,\r\nhtml:has(\r\n    [data-slot=\"drawer-viewport\"][data-direction=\"right\"]\r\n  )::-webkit-scrollbar {\r\n  display: none !important;\r\n}\r\n",
      "type": "registry:file",
      "target": "components/ui/cubby-ui/drawer/drawer.css"
    }
  ],
  "type": "registry:ui"
}