{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-stepper","type":"registry:component","title":"Number Stepper","description":"Spinbutton with −/+ controls whose digits roll in the direction of change, press-and-hold repeat that accelerates, direct typing, arrow/Page/Home/End keys, min/max/step with a shake when pushed past a bound, and Intl.NumberFormat formatting; controlled/uncontrolled.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/number-stepper.tsx","type":"registry:component","target":"@components/motion/number-stepper.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/number-stepper\n\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CLASS, TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberStepperProps {\n  /** Controlled value. */\n  value?: number;\n  /** Uncontrolled initial value. Default `min` when finite, else 0. */\n  defaultValue?: number;\n  onChange?: (value: number) => void;\n  /** Default 0. */\n  min?: number;\n  /** Default Infinity. */\n  max?: number;\n  /** Default 1. */\n  step?: number;\n  /** PageUp/PageDown stride. Default `step * 10`. */\n  largeStep?: number;\n  /** Display formatting, e.g. `{ style: \"currency\", currency: \"USD\" }`. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Formatting locale. Fixed default so server and client render the same text. */\n  locale?: string;\n  /** Visible label above the control. */\n  label?: string;\n  \"aria-label\"?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\n// Press-and-hold: first repeat after HOLD_DELAY, then each interval shrinks by\n// HOLD_ACCEL down to HOLD_MIN — slow enough to stop on a target, fast enough\n// to cross large ranges.\nconst HOLD_DELAY = 400;\nconst HOLD_START = 140;\nconst HOLD_ACCEL = 0.85;\nconst HOLD_MIN = 30;\n\nconst SHAKE = { x: [0, -5, 5, -3, 3, 0] };\n\nconst decimalsOf = (n: number) => {\n  if (!Number.isFinite(n)) return 0;\n  const s = String(n);\n  const e = s.indexOf(\"e-\");\n  if (e >= 0) return Number(s.slice(e + 2));\n  const dot = s.indexOf(\".\");\n  return dot < 0 ? 0 : s.length - dot - 1;\n};\n\nexport function NumberStepper({\n  value: valueProp,\n  defaultValue,\n  onChange,\n  min = 0,\n  max = Number.POSITIVE_INFINITY,\n  step = 1,\n  largeStep,\n  formatOptions,\n  locale = \"en-US\",\n  label,\n  \"aria-label\": ariaLabel,\n  disabled = false,\n  className,\n}: NumberStepperProps) {\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const reduce = useReducedMotion() ?? false;\n\n  const lo = min;\n  const hi = max >= min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const big = largeStep && largeStep > 0 ? largeStep : stride * 10;\n  const decimals = Math.max(decimalsOf(stride), decimalsOf(lo));\n\n  const clampRound = (n: number) =>\n    Number(Math.min(hi, Math.max(lo, n)).toFixed(decimals));\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(() =>\n    clampRound(defaultValue ?? (Number.isFinite(lo) ? lo : 0)),\n  );\n  const value = clampRound(controlled ? valueProp : internal);\n\n  const [draft, setDraft] = useState<string | null>(null);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  // Hold-to-repeat runs on timers, so it reads the latest value from here.\n  const valueRef = useRef(value);\n  const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    valueRef.current = value;\n  }, [value]);\n\n  useEffect(\n    () => () => {\n      if (holdTimer.current) clearTimeout(holdTimer.current);\n    },\n    [],\n  );\n\n  const formatter = useMemo(\n    () => new Intl.NumberFormat(locale, formatOptions),\n    [locale, formatOptions],\n  );\n  const formatted = formatter.format(value);\n  const percent = formatOptions?.style === \"percent\";\n\n  const shake = () => {\n    if (reduce || !fieldRef.current) return;\n    animate(fieldRef.current, SHAKE, { duration: 0.32, ease: EASE_OUT });\n  };\n\n  const commit = (next: number) => {\n    const current = valueRef.current;\n    if (next === current) return;\n    setDirection(next > current ? 1 : -1);\n    valueRef.current = next;\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  /** Steps from `from`; returns false (and shakes) when pinned at a bound. */\n  const stepFrom = (from: number, delta: number) => {\n    const next = clampRound(from + delta);\n    if (next === from) {\n      shake();\n      return false;\n    }\n    commit(next);\n    return true;\n  };\n\n  const parse = (raw: string) => {\n    const cleaned = raw.replace(/[^0-9.-]/g, \"\");\n    if (!cleaned || cleaned === \"-\" || cleaned === \".\") return Number.NaN;\n    const n = Number(cleaned);\n    return percent ? n / 100 : n;\n  };\n\n  /** Applies typed text; returns the value it settled on. */\n  const commitDraft = () => {\n    if (draft === null) return valueRef.current;\n    setDraft(null);\n    const parsed = parse(draft);\n    if (Number.isNaN(parsed)) {\n      shake();\n      return valueRef.current;\n    }\n    const clean = clampRound(parsed);\n    if (clean !== Number(parsed.toFixed(decimals))) shake();\n    commit(clean);\n    return clean;\n  };\n\n  const stopHold = () => {\n    if (holdTimer.current) clearTimeout(holdTimer.current);\n    holdTimer.current = null;\n  };\n\n  const startHold = (delta: number) => {\n    stopHold();\n    if (!stepFrom(commitDraft(), delta)) return;\n    let interval = HOLD_START;\n    const tick = () => {\n      if (!stepFrom(valueRef.current, delta)) {\n        holdTimer.current = null;\n        return;\n      }\n      interval = Math.max(HOLD_MIN, interval * HOLD_ACCEL);\n      holdTimer.current = setTimeout(tick, interval);\n    };\n    holdTimer.current = setTimeout(tick, HOLD_DELAY);\n  };\n\n  const onInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const deltas: Record<string, number> = {\n      ArrowUp: stride,\n      ArrowDown: -stride,\n      PageUp: big,\n      PageDown: -big,\n    };\n    if (e.key in deltas) {\n      e.preventDefault();\n      stepFrom(commitDraft(), deltas[e.key]);\n      inputRef.current?.select();\n      return;\n    }\n    if (e.key === \"Home\" && Number.isFinite(lo)) {\n      e.preventDefault();\n      setDraft(null);\n      if (valueRef.current === lo) shake();\n      commit(lo);\n    } else if (e.key === \"End\" && Number.isFinite(hi)) {\n      e.preventDefault();\n      setDraft(null);\n      if (valueRef.current === hi) shake();\n      commit(hi);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      commitDraft();\n      inputRef.current?.select();\n    } else if (e.key === \"Escape\") {\n      setDraft(null);\n    }\n  };\n\n  const atMin = value <= lo;\n  const atMax = value >= hi;\n  const editing = draft !== null;\n  const integerOnly = decimals === 0 && !percent;\n\n  const button = (dir: 1 | -1) => {\n    const pinned = dir > 0 ? atMax : atMin;\n    const Icon = dir > 0 ? Plus : Minus;\n    return (\n      <motion.button\n        type=\"button\"\n        tabIndex={-1}\n        disabled={disabled}\n        // aria-disabled, not disabled, at a bound: the button must still\n        // receive the press so it can shake back at the user.\n        aria-disabled={pinned || undefined}\n        aria-label={dir > 0 ? \"Increase\" : \"Decrease\"}\n        aria-controls={inputId}\n        whileTap={reduce || pinned ? undefined : { scale: 0.86 }}\n        transition={SPRING_PRESS}\n        onPointerDown={(e: PointerEvent<HTMLButtonElement>) => {\n          if (disabled || e.button !== 0) return;\n          startHold(dir * stride);\n        }}\n        onPointerUp={stopHold}\n        onPointerLeave={stopHold}\n        onPointerCancel={stopHold}\n        onContextMenu={(e: MouseEvent) => e.preventDefault()}\n        onClick={(e: MouseEvent<HTMLButtonElement>) => {\n          // Pointer presses already stepped on pointerdown; this is the\n          // keyboard / assistive-tech path.\n          if (e.detail === 0 && !disabled) stepFrom(commitDraft(), dir * stride);\n        }}\n        className={cn(\n          \"relative grid size-9 shrink-0 place-items-center rounded-full text-foreground outline-none transition-colors duration-150\",\n          \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed\",\n          pinned && \"text-muted-foreground/50 hover:bg-transparent\",\n          TOUCH_GESTURE_CLASS,\n          TOUCH_TARGET_CLASS,\n        )}\n      >\n        <Icon className=\"size-4\" aria-hidden />\n      </motion.button>\n    );\n  };\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          htmlFor={inputId}\n          className=\"px-1 text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n      <div\n        ref={fieldRef}\n        className={cn(\n          \"flex h-12 items-center gap-1 rounded-full border border-border bg-background p-1 transition-colors duration-200\",\n          \"focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40\",\n          disabled && \"opacity-60\",\n        )}\n      >\n        {button(-1)}\n        <div className=\"relative flex h-full min-w-16 flex-1 items-center justify-center\">\n          <input\n            ref={inputRef}\n            id={inputId}\n            type=\"text\"\n            role=\"spinbutton\"\n            inputMode={integerOnly && lo >= 0 ? \"numeric\" : \"decimal\"}\n            autoComplete=\"off\"\n            spellCheck={false}\n            disabled={disabled}\n            aria-label={label ? undefined : (ariaLabel ?? \"Value\")}\n            aria-valuenow={value}\n            aria-valuemin={Number.isFinite(lo) ? lo : undefined}\n            aria-valuemax={Number.isFinite(hi) ? hi : undefined}\n            aria-valuetext={formatted}\n            value={draft ?? formatted}\n            onFocus={(e) => e.currentTarget.select()}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={() => commitDraft()}\n            onKeyDown={onInputKeyDown}\n            size={Math.max(formatted.length, 2)}\n            className={cn(\n              \"h-full w-full bg-transparent text-center text-lg font-semibold tabular-nums outline-none selection:bg-ring/30 disabled:cursor-not-allowed\",\n              // Until the user types, the rolling digits on top do the talking.\n              editing ? \"text-foreground\" : \"text-transparent caret-foreground\",\n            )}\n          />\n          {editing ? null : (\n            <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground\">\n              <RollingText\n                text={formatted}\n                direction={direction}\n                reduce={reduce}\n              />\n            </span>\n          )}\n        </div>\n        {button(1)}\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                Rolling text                                */\n/* -------------------------------------------------------------------------- */\n\n// NumberTicker rolls each digit along a fixed 0–9 column, so 9 → 0 on an\n// increment travels backwards. A stepper needs the roll to follow intent —\n// up rolls up, down rolls down — hence this direction-aware slot.\nconst roll: Variants = {\n  enter: (dir: number) => ({ y: dir > 0 ? \"70%\" : \"-70%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: number) => ({\n    y: dir > 0 ? \"-70%\" : \"70%\",\n    opacity: 0,\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\nfunction RollingText({\n  text,\n  direction,\n  reduce,\n}: {\n  text: string;\n  direction: 1 | -1;\n  reduce: boolean;\n}) {\n  // Rendered without entrance on the first paint (and on the server), so the\n  // number is visible before hydration.\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n\n  const chars = text.split(\"\");\n  // Key by place value from the right, so a digit keeps its slot as the\n  // number grows or shrinks on the left.\n  const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));\n\n  return (\n    <span aria-hidden className=\"inline-flex tabular-nums\">\n      {glyphs.map(({ char, place }) => (\n        <motion.span\n          key={place}\n          layout={reduce ? false : \"position\"}\n          transition={{ layout: SPRING_LAYOUT }}\n          className=\"relative inline-flex h-[1.4em] items-center justify-center overflow-hidden\"\n        >\n          <span className=\"invisible\">{char}</span>\n          <AnimatePresence initial={false} custom={direction}>\n            <motion.span\n              key={char}\n              custom={direction}\n              variants={reduce ? fade : roll}\n              initial={mounted ? \"enter\" : false}\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </motion.span>\n      ))}\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/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"}]}