{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"resizable-panels","type":"registry:component","title":"Resizable Panels","description":"Horizontal or vertical split panes with pointer-captured drag handles, per-panel min/max sizes, collapsible panels that spring shut below a threshold, double-click reset, nestable layouts, and keyboard-resizable separators.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/resizable-panels.tsx","type":"registry:component","target":"@components/motion/resizable-panels.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/resizable-panels\n\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  motionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  Children,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ResizableDirection = \"horizontal\" | \"vertical\";\n\nexport interface ResizablePanelProps {\n  /** Initial size in percent. Panels without one share what is left. */\n  defaultSize?: number;\n  /** Minimum size in percent while expanded. */\n  minSize?: number;\n  /** Maximum size in percent. */\n  maxSize?: number;\n  /** Snap shut when dragged below `collapseThreshold`. */\n  collapsible?: boolean;\n  /** Size in percent the panel rests at when collapsed. */\n  collapsedSize?: number;\n  /** Drag below this size (percent) to collapse. Defaults to half of `minSize`. */\n  collapseThreshold?: number;\n  /** Accessible name for the handle that resizes this panel (the handle after it). */\n  handleLabel?: string;\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Declarative panel. `ResizablePanels` reads these props and renders the\n * panel itself, so this component only renders when used on its own.\n */\nexport function ResizablePanel({ className, children }: ResizablePanelProps) {\n  return <div className={cn(\"min-h-0 min-w-0\", className)}>{children}</div>;\n}\n\nexport interface ResizablePanelsProps {\n  direction?: ResizableDirection;\n  /** Sizes in percent (controlled), one per panel, summing to 100. */\n  sizes?: number[];\n  /** Overrides each panel's `defaultSize`. */\n  defaultSizes?: number[];\n  onSizesChange?: (sizes: number[]) => void;\n  /** Show a grip pill on each handle. */\n  withHandle?: boolean;\n  className?: string;\n  /** `ResizablePanel` elements. Handles are inserted between them. */\n  children: ReactNode;\n}\n\ntype PanelConfig = Required<\n  Pick<\n    ResizablePanelProps,\n    \"minSize\" | \"maxSize\" | \"collapsible\" | \"collapsedSize\" | \"collapseThreshold\"\n  >\n> & {\n  defaultSize?: number;\n  handleLabel?: string;\n  className?: string;\n  children?: ReactNode;\n};\n\nconst KEY_STEP = 2;\nconst KEY_STEP_LARGE = 10;\nconst EPSILON = 0.01;\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\nconst round = (value: number) => Math.round(value * 100) / 100;\n\nfunction resolveDefaults(configs: PanelConfig[], override?: number[]) {\n  if (override && override.length === configs.length) return override;\n  const fixed = configs.reduce((sum, c) => sum + (c.defaultSize ?? 0), 0);\n  const free = configs.filter((c) => c.defaultSize === undefined).length;\n  const share = free ? Math.max(0, 100 - fixed) / free : 0;\n  return configs.map((c) => c.defaultSize ?? share);\n}\n\nfunction PanelFrame({\n  id,\n  size,\n  config,\n  collapsed,\n}: {\n  id: string;\n  size: MotionValue<number>;\n  config: PanelConfig;\n  collapsed: boolean;\n}) {\n  // Flex-grow proportional to the percentage: handles keep their pixel width\n  // and the panels split exactly what is left.\n  const flex = useTransform(size, (v) => `${Math.max(v, 0)} 1 0px`);\n  return (\n    <motion.div\n      id={id}\n      data-collapsed={collapsed || undefined}\n      inert={collapsed}\n      style={{ flex }}\n      className={cn(\"relative min-h-0 min-w-0 overflow-hidden\", config.className)}\n    >\n      {config.children}\n    </motion.div>\n  );\n}\n\nexport function ResizablePanels({\n  direction = \"horizontal\",\n  sizes: sizesProp,\n  defaultSizes,\n  onSizesChange,\n  withHandle = true,\n  className,\n  children,\n}: ResizablePanelsProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const horizontal = direction === \"horizontal\";\n\n  const configs = useMemo<PanelConfig[]>(\n    () =>\n      Children.toArray(children)\n        .filter((child): child is ReactElement<ResizablePanelProps> =>\n          isValidElement(child),\n        )\n        .map(({ props }) => {\n          const minSize = props.minSize ?? 10;\n          return {\n            defaultSize: props.defaultSize,\n            minSize,\n            maxSize: props.maxSize ?? 100,\n            collapsible: props.collapsible ?? false,\n            collapsedSize: props.collapsedSize ?? 0,\n            collapseThreshold:\n              props.collapseThreshold ??\n              ((props.collapsedSize ?? 0) + minSize) / 2,\n            handleLabel: props.handleLabel,\n            className: props.className,\n            children: props.children,\n          };\n        }),\n    [children],\n  );\n\n  const [initial] = useState(() => resolveDefaults(configs, defaultSizes));\n  const [internal, setInternal] = useState(initial);\n  const controlled = sizesProp !== undefined;\n  const sizes = controlled ? sizesProp : internal;\n\n  // One motion value per panel drives layout; React state stays the source\n  // of truth for aria values. Created once per panel slot.\n  const motionSizes = useRef<MotionValue<number>[]>([]);\n  if (motionSizes.current.length !== configs.length) {\n    motionSizes.current = configs.map(\n      (_, i) => motionSizes.current[i] ?? motionValue(sizes[i] ?? 0),\n    );\n  }\n  const targets = useRef<number[]>([...sizes]);\n  const animateNext = useRef(new Set<number>());\n\n  useEffect(() => {\n    sizes.forEach((size, i) => {\n      const mv = motionSizes.current[i];\n      if (!mv || Math.abs((targets.current[i] ?? -1) - size) < EPSILON) return;\n      targets.current[i] = size;\n      if (animateNext.current.has(i) && !reduce) {\n        animate(mv, size, SPRING_LAYOUT);\n      } else {\n        mv.stop();\n        mv.set(size);\n      }\n    });\n    animateNext.current.clear();\n  }, [sizes, reduce]);\n\n  const commit = useCallback(\n    (next: number[], animated: number[] = []) => {\n      for (const i of animated) animateNext.current.add(i);\n      const rounded = next.map(round);\n      if (!controlled) setInternal(rounded);\n      onSizesChange?.(rounded);\n    },\n    [controlled, onSizesChange],\n  );\n\n  const isCollapsed = useCallback(\n    (i: number, value = sizes[i] ?? 0) => {\n      const config = configs[i];\n      return Boolean(\n        config?.collapsible && value <= config.collapsedSize + EPSILON,\n      );\n    },\n    [configs, sizes],\n  );\n\n  /**\n   * Resolve a requested size for panel `i` against the pair (i, i + 1):\n   * clamps to min/max on both sides and snaps collapsible panels shut below\n   * their threshold.\n   */\n  const resolvePair = useCallback(\n    (base: number[], i: number, requested: number) => {\n      const a = configs[i];\n      const b = configs[i + 1];\n      if (!a || !b) return base;\n      const total = (base[i] ?? 0) + (base[i + 1] ?? 0);\n\n      const settle = (config: PanelConfig, raw: number) => {\n        if (raw < config.minSize) {\n          if (config.collapsible && raw < config.collapseThreshold) {\n            return config.collapsedSize;\n          }\n          return config.minSize;\n        }\n        return Math.min(raw, config.maxSize);\n      };\n\n      let nextA = settle(a, clamp(requested, 0, total));\n      let nextB = settle(b, total - nextA);\n      nextA = total - nextB;\n      // If B's limits pushed A out of its own range, re-settle A and give B the rest.\n      if (nextA > a.maxSize || (nextA < a.minSize && !isCollapsedFor(a, nextA))) {\n        nextA = settle(a, nextA);\n        nextB = total - nextA;\n      }\n\n      const next = [...base];\n      next[i] = nextA;\n      next[i + 1] = nextB;\n      return next;\n    },\n    [configs],\n  );\n\n  const drag = useRef<{\n    index: number;\n    pointerId: number;\n    start: number;\n    length: number;\n    base: number[];\n  } | null>(null);\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>, index: number) => {\n    if (event.button !== 0) return;\n    const rect = containerRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    event.preventDefault();\n    event.currentTarget.focus();\n    capturePointer(event.currentTarget, event.pointerId);\n    drag.current = {\n      index,\n      pointerId: event.pointerId,\n      start: horizontal ? event.clientX : event.clientY,\n      length: horizontal ? rect.width : rect.height,\n      base: [...sizes],\n    };\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state || state.pointerId !== event.pointerId || !state.length) return;\n    const delta =\n      (((horizontal ? event.clientX : event.clientY) - state.start) /\n        state.length) *\n      100;\n    const next = resolvePair(\n      state.base,\n      state.index,\n      (state.base[state.index] ?? 0) + delta,\n    );\n    if (next.every((v, i) => Math.abs(v - (sizes[i] ?? 0)) < EPSILON)) return;\n    // Snaps animate only when the collapsed state flips relative to now.\n    const pair = [state.index, state.index + 1];\n    const flipped = pair.some((i) => isCollapsed(i, next[i]) !== isCollapsed(i));\n    commit(next, flipped ? pair : []);\n  };\n\n  const endDrag = (event: PointerEvent<HTMLDivElement>) => {\n    if (drag.current?.pointerId !== event.pointerId) return;\n    releasePointer(event.currentTarget, event.pointerId);\n    drag.current = null;\n  };\n\n  const resizeBy = (index: number, requested: number) => {\n    commit(resolvePair(sizes, index, requested), [index, index + 1]);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number) => {\n    const config = configs[index];\n    if (!config) return;\n    const current = sizes[index] ?? 0;\n    const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP;\n    const decrease = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n    const increase = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n    switch (event.key) {\n      case decrease:\n        event.preventDefault();\n        // From the minimum, one more step snaps a collapsible panel shut.\n        resizeBy(\n          index,\n          config.collapsible && current - step < config.minSize\n            ? config.collapsedSize\n            : current - step,\n        );\n        return;\n      case increase:\n        event.preventDefault();\n        resizeBy(\n          index,\n          isCollapsed(index) ? Math.max(config.minSize, current + step) : current + step,\n        );\n        return;\n      case \"Home\":\n        event.preventDefault();\n        resizeBy(index, config.collapsible ? config.collapsedSize : config.minSize);\n        return;\n      case \"End\":\n        event.preventDefault();\n        resizeBy(index, config.maxSize);\n        return;\n      case \"Enter\":\n        if (!config.collapsible) return;\n        event.preventDefault();\n        resizeBy(\n          index,\n          isCollapsed(index)\n            ? (initial[index] ?? config.minSize) > config.collapsedSize\n              ? (initial[index] ?? config.minSize)\n              : config.minSize\n            : config.collapsedSize,\n        );\n        return;\n      default:\n        return;\n    }\n  };\n\n  const resetAll = () => {\n    commit(\n      [...initial],\n      initial.map((_, i) => i),\n    );\n  };\n\n  const items: ReactNode[] = [];\n  configs.forEach((config, i) => {\n    const mv = motionSizes.current[i];\n    if (!mv) return;\n    const panelId = `${baseId}-panel-${i}`;\n    items.push(\n      <PanelFrame\n        key={panelId}\n        id={panelId}\n        size={mv}\n        config={config}\n        collapsed={isCollapsed(i)}\n      />,\n    );\n    if (i < configs.length - 1) {\n      const value = Math.round(sizes[i] ?? 0);\n      items.push(\n        // biome-ignore lint/a11y/useSemanticElements: a focusable window splitter (WAI-ARIA) is a separator widget; <hr> cannot take focus or children.\n        <div\n          key={`${panelId}-handle`}\n          role=\"separator\"\n          tabIndex={0}\n          aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n          aria-controls={panelId}\n          aria-label={config.handleLabel ?? \"Resize panels\"}\n          aria-valuenow={value}\n          aria-valuemin={Math.round(\n            config.collapsible ? config.collapsedSize : config.minSize,\n          )}\n          aria-valuemax={Math.round(config.maxSize)}\n          onPointerDown={(event) => onPointerDown(event, i)}\n          onPointerMove={onPointerMove}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onKeyDown={(event) => onKeyDown(event, i)}\n          onDoubleClick={resetAll}\n          className={cn(\n            \"group/handle relative z-10 flex shrink-0 touch-none items-center justify-center bg-border outline-none\",\n            TOUCH_GESTURE_CLASS,\n            horizontal\n              ? \"w-px cursor-col-resize before:absolute before:inset-y-0 before:-inset-x-1.5 before:content-['']\"\n              : \"h-px cursor-row-resize before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-['']\",\n            \"transition-colors hover:bg-ring/60 focus-visible:bg-ring\",\n          )}\n        >\n          {withHandle ? (\n            <span\n              aria-hidden\n              className={cn(\n                \"pointer-events-none relative rounded-full border border-border-strong bg-background shadow-sm transition-transform duration-150\",\n                \"group-hover/handle:scale-110 group-focus-visible/handle:border-ring\",\n                horizontal ? \"h-6 w-1.5\" : \"h-1.5 w-6\",\n              )}\n            />\n          ) : null}\n        </div>,\n      );\n    }\n  });\n\n  return (\n    <div\n      ref={containerRef}\n      data-direction={direction}\n      className={cn(\n        \"flex h-full w-full overflow-hidden\",\n        horizontal ? \"flex-row\" : \"flex-col\",\n        className,\n      )}\n    >\n      {items}\n    </div>\n  );\n}\n\nfunction isCollapsedFor(config: PanelConfig, value: number) {\n  return config.collapsible && value <= config.collapsedSize + EPSILON;\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/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":"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"}]}