{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"model-selector","type":"registry:component","title":"Model Selector","description":"A composer-ready model and reasoning-effort control: the first view manages effort, the current model opens a full picker, and both views share a stable collision-aware panel with complete pointer and keyboard navigation.","author":"AgentUI","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/model-selector.tsx","type":"registry:component","target":"@components/agents/model-selector.tsx","content":"\"use client\";\n// www.agentui.pro/components/agents/model-selector\n\nimport {\n  Check,\n  ChevronDown,\n  ChevronLeft,\n  ChevronRight,\n  RotateCcw,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  EASE_OUT,\n  SPRING_GLIDE,\n  SPRING_LAYOUT,\n  SPRING_PANEL,\n  SPRING_PRESS,\n  SPRING_SWAP,\n} from \"@/lib/ease\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useSlider } from \"@/lib/hooks/use-slider\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\ntype PanelView = \"effort\" | \"models\";\n\nconst VIEWPORT_PADDING = 12;\nconst PANEL_WIDTH = 272;\nconst PANEL_MAX_HEIGHT = 320;\n\nexport interface ModelSelectorEffortOption {\n  value: string;\n  label: string;\n  description?: string;\n  disabled?: boolean;\n}\n\nexport const DEFAULT_MODEL_EFFORTS: readonly ModelSelectorEffortOption[] = [\n  { value: \"low\", label: \"Low\" },\n  { value: \"medium\", label: \"Medium\" },\n  { value: \"high\", label: \"High\" },\n  { value: \"xhigh\", label: \"X-high\" },\n  { value: \"max\", label: \"Max\" },\n  { value: \"ultra\", label: \"Ultra\" },\n];\n\nexport interface ModelSelectorOption {\n  value: string;\n  label: ReactNode;\n  /** Accessible text when `label` is not a string. Falls back to `value`. */\n  textValue?: string;\n  description?: ReactNode;\n  icon?: ReactNode;\n  badge?: ReactNode;\n  disabled?: boolean;\n  /** Model-specific effort choices. Falls back to the root `efforts` prop. */\n  efforts?: readonly ModelSelectorEffortOption[];\n  defaultEffort?: string;\n}\n\nexport interface ModelSelectorProps {\n  models: readonly ModelSelectorOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  efforts?: readonly ModelSelectorEffortOption[];\n  effort?: string;\n  defaultEffort?: string;\n  onEffortChange?: (effort: string) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  placeholder?: ReactNode;\n  /** Accessible name for the trigger and effort panel. */\n  label?: string;\n  modelListLabel?: string;\n  effortLabel?: string;\n  side?: Side;\n  align?: Align;\n  sideOffset?: number;\n  className?: string;\n  triggerClassName?: string;\n  contentClassName?: string;\n}\n\nfunction optionText(option: ModelSelectorOption | undefined) {\n  if (!option) return undefined;\n  if (option.textValue) return option.textValue;\n  if (typeof option.label === \"string\" || typeof option.label === \"number\") {\n    return String(option.label);\n  }\n  return option.value;\n}\n\nfunction enabledModelValue(\n  models: readonly ModelSelectorOption[],\n  candidate: string | null | undefined,\n) {\n  return candidate !== null && candidate !== undefined\n    ? models.find((model) => model.value === candidate && !model.disabled)?.value ??\n        null\n    : null;\n}\n\nfunction enabledEffortValue(\n  efforts: readonly ModelSelectorEffortOption[],\n  candidate: string | null | undefined,\n) {\n  return candidate !== null && candidate !== undefined\n    ? efforts.find((effort) => effort.value === candidate && !effort.disabled)\n        ?.value ?? null\n    : null;\n}\n\nfunction effortsFor(\n  model: ModelSelectorOption | undefined,\n  fallback: readonly ModelSelectorEffortOption[],\n) {\n  return model?.efforts ?? fallback;\n}\n\ntype TriggerRect = {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  viewportWidth: number;\n  viewportHeight: number;\n};\n\nfunction sameTriggerRect(current: TriggerRect | null, next: TriggerRect) {\n  return (\n    current?.left === next.left &&\n    current.top === next.top &&\n    current.width === next.width &&\n    current.height === next.height &&\n    current.viewportWidth === next.viewportWidth &&\n    current.viewportHeight === next.viewportHeight\n  );\n}\n\n/** Measure only the anchor. Panel size changes must never feed positioning. */\nfunction useTriggerRect(\n  triggerRef: RefObject<HTMLElement | null>,\n  active: boolean,\n) {\n  const [rect, setRect] = useState<TriggerRect | null>(null);\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const box = trigger.getBoundingClientRect();\n    const next = {\n      left: box.left,\n      top: box.top,\n      width: box.width,\n      height: box.height,\n      viewportWidth: window.innerWidth,\n      viewportHeight: window.innerHeight,\n    };\n    setRect((current) => (sameTriggerRect(current, next) ? current : next));\n  }, [triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n    const trigger = triggerRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, triggerRef, update]);\n\n  return rect;\n}\n\n/** Keep the active model valid while a streamed catalog changes identity. */\nfunction useModelCursor({\n  open,\n  models,\n  value,\n}: {\n  open: boolean;\n  models: readonly ModelSelectorOption[];\n  value: string | undefined;\n}) {\n  const [cursor, setCursor] = useState<string | null>(null);\n  const enabled = useMemo(\n    () => models.filter((model) => !model.disabled),\n    [models],\n  );\n  const liveCursor = enabledModelValue(enabled, cursor);\n\n  if (cursor !== null && liveCursor === null) setCursor(null);\n\n  const fallback = enabledModelValue(enabled, value) ?? enabled[0]?.value ?? null;\n  const activeValue = liveCursor ?? fallback;\n  const latest = useRef({ open, enabled, value });\n\n  useLayoutEffect(() => {\n    latest.current = { open, enabled, value };\n  });\n\n  const reset = useCallback(() => setCursor(null), []);\n  const moveTo = useCallback(\n    (next: string | null) =>\n      setCursor((current) => (current === next ? current : next)),\n    [],\n  );\n  const move = useCallback((direction: 1 | -1 | \"first\" | \"last\") => {\n    const current = latest.current;\n    if (!current.open || current.enabled.length === 0) return;\n\n    setCursor((previous) => {\n      const rows = current.enabled;\n      const last = rows.length - 1;\n      const from =\n        enabledModelValue(rows, previous) ??\n        enabledModelValue(rows, current.value) ??\n        rows[0]?.value ??\n        null;\n      const at = Math.max(\n        rows.findIndex((model) => model.value === from),\n        0,\n      );\n      const index =\n        direction === \"first\"\n          ? 0\n          : direction === \"last\"\n            ? last\n            : (at + direction + rows.length) % rows.length;\n      return rows[index]?.value ?? null;\n    });\n  }, []);\n\n  useOnOpen(open, reset);\n\n  return { activeValue, move, moveTo };\n}\n\nfunction EffortSlider({\n  efforts,\n  value,\n  onValueChange,\n  label,\n  disabled,\n  reduce,\n}: {\n  efforts: readonly ModelSelectorEffortOption[];\n  value: string | undefined;\n  onValueChange: (value: string) => void;\n  label: string;\n  disabled: boolean;\n  reduce: boolean;\n}) {\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [dragging, setDragging] = useState(false);\n  const activePointer = useRef<number | null>(null);\n  const animation = useRef<{ stop: () => void } | null>(null);\n  const enabled = efforts.filter((effort) => !effort.disabled);\n  const resolved =\n    enabled.findIndex((effort) => effort.value === value) >= 0\n      ? (value as string)\n      : enabled[0]?.value;\n  const index = Math.max(\n    enabled.findIndex((effort) => effort.value === resolved),\n    0,\n  );\n  const lastEmitted = useRef(value);\n  useLayoutEffect(() => {\n    lastEmitted.current = value;\n  }, [value]);\n\n  const { percent, current, commit, sliderProps } = useSlider({\n    value: index,\n    min: 0,\n    max: Math.max(enabled.length - 1, 0),\n    step: 1,\n    disabled: disabled || enabled.length < 2,\n    \"aria-label\": label,\n    formatValueText: (numeric) => enabled[Math.round(numeric)]?.label ?? \"\",\n    onValueChange: (numeric) => {\n      const next = enabled[Math.round(numeric)];\n      if (next && next.value !== lastEmitted.current) {\n        lastEmitted.current = next.value;\n        onValueChange(next.value);\n      }\n    },\n  });\n  const position = useMotionValue(percent);\n  useLayoutEffect(() => {\n    if (dragging || activePointer.current !== null) return;\n    if (reduce) {\n      position.set(percent);\n      return;\n    }\n    animation.current = animate(position, percent, {\n      type: \"spring\",\n      ...SPRING_GLIDE,\n    });\n    return () => animation.current?.stop();\n  }, [dragging, percent, position, reduce]);\n  const thumbX = useMotionTemplate`${position}%`;\n  const fillScale = useTransform(position, (progress) => progress / 100);\n  const currentOption = enabled[Math.round(current)];\n  const denominator = Math.max(enabled.length - 1, 1);\n\n  const movePointer = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const track = event.currentTarget;\n    const rect = track.getBoundingClientRect();\n    if (rect.width <= 0) return;\n    // Pointer coordinates and the visual thumb share the same inset endpoints,\n    // including while the opening panel is still scaled.\n    const inset = 14 * (track.offsetWidth ? rect.width / track.offsetWidth : 1);\n    const travel = Math.max(rect.width - inset * 2, 1);\n    const progress = Math.min(1, Math.max(0, (event.clientX - rect.left - inset) / travel));\n    position.set(progress * 100);\n    commit(progress * Math.max(enabled.length - 1, 0));\n  };\n  const endPointer = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.pointerId !== activePointer.current) return;\n    activePointer.current = null;\n    setDragging(false);\n    releasePointer(event.currentTarget, event.pointerId);\n  };\n\n  if (enabled.length === 0) {\n    return (\n      <p className=\"mt-3 text-center text-xs text-muted-foreground\">\n        Effort is managed automatically for this model.\n      </p>\n    );\n  }\n\n  return (\n    <div\n      onPointerDown={(event) => {\n        if (disabled || enabled.length < 2 || event.button !== 0 || activePointer.current !== null) return;\n        event.preventDefault();\n        animation.current?.stop();\n        activePointer.current = event.pointerId;\n        setDragging(true);\n        capturePointer(event.currentTarget, event.pointerId);\n        event.currentTarget.querySelector<HTMLElement>('[role=\"slider\"]')?.focus({ preventScroll: true });\n        movePointer(event);\n      }}\n      onPointerMove={(event) => {\n        if (!disabled && event.pointerId === activePointer.current) movePointer(event);\n      }}\n      onPointerUp={(event) => {\n        if (!disabled && event.pointerId === activePointer.current) movePointer(event);\n        endPointer(event);\n      }}\n      onPointerCancel={endPointer}\n      onLostPointerCapture={endPointer}\n      className={cn(\n        \"relative mt-3 h-8 w-full touch-none\",\n        TOUCH_GESTURE_CLASS,\n        disabled || enabled.length < 2\n          ? \"pointer-events-none opacity-50\"\n          : \"cursor-grab active:cursor-grabbing\",\n      )}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n    >\n      <div className=\"pointer-events-none absolute inset-x-0 top-1/2 h-6 -translate-y-1/2 overflow-hidden rounded-full bg-foreground/5\">\n        <motion.span\n          className=\"absolute inset-0 origin-left bg-accent\"\n          style={{ scaleX: fillScale }}\n        />\n        <span className=\"absolute inset-0 rounded-full ring-1 ring-inset ring-border-strong\" />\n      </div>\n      <div className=\"pointer-events-none absolute inset-x-3.5 inset-y-0\">\n        {enabled.map((effort, effortIndex) => {\n          const tickLeft =\n            enabled.length === 1\n              ? \"0%\"\n              : `${(effortIndex / denominator) * 100}%`;\n          return (\n            <span\n              key={effort.value}\n              style={{ left: tickLeft }}\n              className={cn(\n                \"absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full\",\n                effortIndex <= index\n                  ? \"bg-accent-foreground/45\"\n                  : \"bg-muted-foreground/55\",\n              )}\n            />\n          );\n        })}\n      </div>\n      <motion.div\n        className=\"pointer-events-none absolute inset-y-0 left-0 right-7\"\n        style={{ x: thumbX }}\n      >\n      <motion.span\n        {...sliderProps}\n        className=\"pointer-events-auto absolute left-0 top-1/2 z-10 size-7 rounded-full outline-none\"\n        style={{ y: \"-50%\" }}\n        onFocus={() => setFocused(true)}\n        onBlur={() => setFocused(false)}\n      >\n        <motion.span\n          animate={reduce ? undefined : { scale: dragging ? 1.08 : 1 }}\n          transition={SPRING_PRESS}\n          className={cn(\n            \"block size-7 rounded-full bg-foreground shadow-[0_4px_12px_rgba(0,0,0,0.18)]\",\n            focused && \"ring-2 ring-accent ring-offset-2 ring-offset-popover\",\n          )}\n        />\n        <AnimatePresence>\n          {(hovered || focused || dragging) && currentOption?.description ? (\n            <motion.span\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: 3, scale: 0.96 }\n              }\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={{ opacity: 0, y: 2, scale: 0.98 }}\n              transition={reduce ? { duration: 0.1 } : SPRING_PANEL}\n              className={cn(\n                \"pointer-events-none absolute top-full mt-2 max-w-48 rounded-xl border border-border bg-popover px-2.5 py-1.5 text-left shadow-lg\",\n                current === 0\n                  ? \"left-0\"\n                  : current === enabled.length - 1\n                    ? \"right-0\"\n                    : \"left-1/2 -translate-x-1/2\",\n              )}\n            >\n              <span className=\"block truncate text-xs text-foreground\">\n                {currentOption.description}\n              </span>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </motion.span>\n      </motion.div>\n    </div>\n  );\n}\n\nexport function ModelSelector({\n  models,\n  value,\n  defaultValue,\n  onValueChange,\n  efforts = DEFAULT_MODEL_EFFORTS,\n  effort,\n  defaultEffort = \"high\",\n  onEffortChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  placeholder = \"Select model\",\n  label = \"Model and effort\",\n  modelListLabel = \"Select model\",\n  effortLabel = \"Reasoning effort\",\n  side = \"top\",\n  align = \"end\",\n  sideOffset = 8,\n  className,\n  triggerClassName,\n  contentClassName,\n}: ModelSelectorProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const triggerId = `${baseId}-trigger`;\n  const panelId = `${baseId}-panel`;\n  const listId = `${baseId}-list`;\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const anchorRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const modelButtonRef = useRef<HTMLButtonElement>(null);\n  const focusActiveModel = useRef(false);\n  const focusModelButton = useRef(false);\n  const effortByModel = useRef(new Map<string, string>());\n  const [portalReady, setPortalReady] = useState(false);\n  const [view, setView] = useState<PanelView>(\"effort\");\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalEffort, setInternalEffort] = useState(defaultEffort);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlledValue = value !== undefined;\n  const controlledEffort = effort !== undefined;\n  const controlledOpen = openProp !== undefined;\n  const currentValue = controlledValue ? value : internalValue;\n  const rawEffort = controlledEffort ? effort : internalEffort;\n  const open = controlledOpen ? openProp : internalOpen;\n  const currentModel = models.find((model) => model.value === currentValue);\n  const currentText = optionText(currentModel);\n  const currentEfforts = useMemo(\n    () => effortsFor(currentModel, efforts),\n    [currentModel, efforts],\n  );\n  const currentEffortValue =\n    enabledEffortValue(currentEfforts, rawEffort) ??\n    enabledEffortValue(currentEfforts, currentModel?.defaultEffort) ??\n    enabledEffortValue(currentEfforts, defaultEffort) ??\n    currentEfforts.find((option) => !option.disabled)?.value;\n  const currentEffort = currentEfforts.find(\n    (option) => option.value === currentEffortValue,\n  );\n  const resetEffortValue =\n    enabledEffortValue(currentEfforts, currentModel?.defaultEffort) ??\n    enabledEffortValue(currentEfforts, defaultEffort) ??\n    currentEfforts.find((option) => !option.disabled)?.value;\n\n  useLayoutEffect(() => {\n    if (currentValue && currentEffortValue) {\n      effortByModel.current.set(currentValue, currentEffortValue);\n    }\n  }, [currentEffortValue, currentValue]);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlledOpen) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n\n  const commitEffort = useCallback(\n    (next: string) => {\n      if (currentValue) effortByModel.current.set(currentValue, next);\n      if (!controlledEffort) setInternalEffort(next);\n      onEffortChange?.(next);\n    },\n    [controlledEffort, currentValue, onEffortChange],\n  );\n\n  const { activeValue, move, moveTo } = useModelCursor({\n    open,\n    models,\n    value: currentValue,\n  });\n  const activeIndex = models.findIndex(\n    (model) => model.value === activeValue,\n  );\n\n  const resetView = useCallback(() => setView(\"effort\"), []);\n  useOnOpen(open, resetView);\n\n  const showModels = useCallback(() => {\n    focusActiveModel.current = true;\n    setView(\"models\");\n  }, []);\n\n  const showEffort = useCallback(() => {\n    focusModelButton.current = true;\n    setView(\"effort\");\n  }, []);\n\n  const choose = useCallback(\n    (next: string) => {\n      const model = models.find((option) => option.value === next);\n      if (!model || model.disabled) return;\n      const nextEfforts = effortsFor(model, efforts);\n      const nextEffort =\n        enabledEffortValue(nextEfforts, effortByModel.current.get(next)) ??\n        enabledEffortValue(nextEfforts, currentEffortValue) ??\n        enabledEffortValue(nextEfforts, model.defaultEffort) ??\n        enabledEffortValue(nextEfforts, defaultEffort) ??\n        nextEfforts.find((option) => !option.disabled)?.value;\n\n      if (!controlledValue) setInternalValue(next);\n      onValueChange?.(next);\n      if (nextEffort && nextEffort !== currentEffortValue) {\n        if (!controlledEffort) setInternalEffort(nextEffort);\n        onEffortChange?.(nextEffort);\n      }\n      if (nextEffort) effortByModel.current.set(next, nextEffort);\n      focusModelButton.current = true;\n      setView(\"effort\");\n    },\n    [\n      controlledEffort,\n      controlledValue,\n      currentEffortValue,\n      defaultEffort,\n      efforts,\n      models,\n      onEffortChange,\n      onValueChange,\n    ],\n  );\n\n  useEffect(() => setPortalReady(true), []);\n\n  useLayoutEffect(() => {\n    if (view !== \"models\" || !focusActiveModel.current || activeIndex < 0) {\n      return;\n    }\n    focusActiveModel.current = false;\n    document.getElementById(`${baseId}-option-${activeIndex}`)?.focus();\n  }, [activeIndex, baseId, view]);\n\n  useLayoutEffect(() => {\n    if (!open || view !== \"effort\" || !focusModelButton.current) return;\n    focusModelButton.current = false;\n    modelButtonRef.current?.focus();\n  }, [open, view]);\n\n  useEffect(() => {\n    if (!open) return;\n    const handlePointerDown = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (\n        !triggerRef.current?.contains(target) &&\n        !panelRef.current?.contains(target)\n      ) {\n        setOpen(false);\n      }\n    };\n    const handleFocus = (event: FocusEvent) => {\n      const target = event.target as Node;\n      if (\n        !triggerRef.current?.contains(target) &&\n        !panelRef.current?.contains(target)\n      ) {\n        setOpen(false);\n      }\n    };\n    const handleEscape = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      if (view === \"models\") showEffort();\n      else {\n        setOpen(false);\n        triggerRef.current?.focus();\n      }\n    };\n\n    window.addEventListener(\"pointerdown\", handlePointerDown);\n    window.addEventListener(\"focusin\", handleFocus);\n    window.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      window.removeEventListener(\"pointerdown\", handlePointerDown);\n      window.removeEventListener(\"focusin\", handleFocus);\n      window.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [open, setOpen, showEffort, view]);\n\n  // The button scales during a press; its stationary wrapper is the anchor.\n  const triggerRect = useTriggerRect(anchorRef, portalReady && open);\n  const panelWidth = triggerRect\n    ? Math.min(PANEL_WIDTH, window.innerWidth - VIEWPORT_PADDING * 2)\n    : PANEL_WIDTH;\n  let placement = side;\n  if (triggerRect) {\n    const above = triggerRect.top - sideOffset - VIEWPORT_PADDING;\n    const below =\n      window.innerHeight -\n      triggerRect.top -\n      triggerRect.height -\n      sideOffset -\n      VIEWPORT_PADDING;\n    if (side === \"top\" && above < PANEL_MAX_HEIGHT && below > above) {\n      placement = \"bottom\";\n    } else if (\n      side === \"bottom\" &&\n      below < PANEL_MAX_HEIGHT &&\n      above > below\n    ) {\n      placement = \"top\";\n    }\n  }\n\n  const panelLeft = triggerRect\n    ? Math.min(\n        Math.max(\n          VIEWPORT_PADDING,\n          align === \"end\"\n            ? triggerRect.left + triggerRect.width - panelWidth\n            : triggerRect.left,\n        ),\n        Math.max(\n          VIEWPORT_PADDING,\n          window.innerWidth - panelWidth - VIEWPORT_PADDING,\n        ),\n      )\n    : 0;\n  const panelAnchorTop = triggerRect\n    ? placement === \"top\"\n      ? triggerRect.top - sideOffset\n      : triggerRect.top + triggerRect.height + sideOffset\n    : 0;\n  const availablePanelHeight = triggerRect\n    ? placement === \"top\"\n      ? triggerRect.top - sideOffset - VIEWPORT_PADDING\n      : window.innerHeight -\n        triggerRect.top -\n        triggerRect.height -\n        sideOffset -\n        VIEWPORT_PADDING\n    : PANEL_MAX_HEIGHT;\n\n  const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      focusModelButton.current = true;\n      if (!open) setOpen(true);\n      else {\n        focusModelButton.current = false;\n        modelButtonRef.current?.focus();\n      }\n      return;\n    }\n    if (event.key === \"Tab\" && open && !event.shiftKey) {\n      event.preventDefault();\n      modelButtonRef.current?.focus();\n    }\n  };\n\n  const handleModelKeyDown = (\n    event: KeyboardEvent<HTMLButtonElement>,\n    selectedValue: string,\n  ) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      focusActiveModel.current = true;\n      move(event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      focusActiveModel.current = true;\n      move(event.key === \"Home\" ? \"first\" : \"last\");\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      choose(selectedValue);\n    }\n  };\n\n  const effortView = (\n    <PresenceGate key=\"effort\">\n      {({ gate, isPresent }) => (\n        <motion.div\n          inert={gate.inert}\n          aria-hidden={!isPresent}\n          style={gate.style}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={\n            reduce\n              ? { duration: 0.1 }\n              : { duration: 0.18, ease: EASE_OUT }\n          }\n          className={cn(\n            \"relative px-3 pt-3\",\n            currentEfforts.some((option) => option.description)\n              ? \"pb-11\"\n              : \"pb-3\",\n          )}\n        >\n      <div className=\"grid grid-cols-[2rem_1fr_2rem] items-start gap-1\">\n        <span\n          aria-hidden=\"true\"\n          className=\"block size-8\"\n        />\n\n        <div className=\"min-w-0 text-center\">\n          <div className=\"relative h-5 overflow-hidden\">\n              <motion.div\n                key={currentEffortValue ?? \"automatic\"}\n                initial={reduce ? false : { opacity: 0.65, y: 2 }}\n                animate={{ opacity: 1, y: 0 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : { duration: 0.12, ease: EASE_OUT }\n                }\n                className=\"absolute inset-0 text-sm font-medium text-accent\"\n              >\n                {currentEffort?.label ?? \"Automatic\"}\n              </motion.div>\n          </div>\n          <button\n            ref={modelButtonRef}\n            type=\"button\"\n            onClick={showModels}\n            aria-label={`Change model: ${currentText ?? \"None selected\"}`}\n            className=\"group/model mt-0.5 inline-flex max-w-full items-center gap-0.5 rounded-lg px-1.5 py-0.5 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <span className=\"truncate\">\n              {currentModel?.label ?? placeholder}\n            </span>\n            <ChevronRight className=\"size-3 shrink-0 transition-transform group-hover/model:translate-x-0.5\" />\n          </button>\n        </div>\n\n        <motion.button\n          type=\"button\"\n          aria-label=\"Reset effort\"\n          disabled={!resetEffortValue || currentEffortValue === resetEffortValue}\n          onClick={() => {\n            if (resetEffortValue) commitEffort(resetEffortValue);\n          }}\n          whileTap={reduce ? undefined : { rotate: -35, scale: 0.92 }}\n          transition={SPRING_PRESS}\n          className=\"grid size-8 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-35\"\n        >\n          <RotateCcw className=\"size-3.5\" />\n        </motion.button>\n      </div>\n\n          <EffortSlider\n            key={currentValue ?? \"no-model\"}\n            efforts={currentEfforts}\n            value={currentEffortValue}\n            onValueChange={commitEffort}\n            label={effortLabel}\n            disabled={disabled}\n            reduce={reduce}\n          />\n        </motion.div>\n      )}\n    </PresenceGate>\n  );\n\n  const modelView = (\n    <PresenceGate key=\"models\">\n      {({ gate, isPresent }) => (\n        <motion.div\n          inert={gate.inert}\n          aria-hidden={!isPresent}\n          style={gate.style}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          className=\"relative\"\n          transition={\n            reduce\n              ? { duration: 0.1 }\n              : { duration: 0.18, ease: EASE_OUT }\n          }\n        >\n      <button\n        type=\"button\"\n        onClick={showEffort}\n        className=\"group/back mx-1.5 mt-1.5 flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <ChevronLeft className=\"size-3.5 transition-transform group-hover/back:-translate-x-0.5\" />\n        {modelListLabel}\n      </button>\n      <motion.div\n        id={listId}\n        role=\"listbox\"\n        aria-label={modelListLabel}\n        style={{ maxHeight: Math.max(48, Math.min(352, availablePanelHeight - 44)) }}\n        className=\"max-h-[min(22rem,calc(100vh-5rem))] overflow-y-auto overscroll-contain px-1.5 pb-1.5 [scrollbar-color:var(--color-border-strong)_transparent] [scrollbar-width:thin]\"\n      >\n        {models.length === 0 ? (\n          <p className=\"px-2 py-4 text-sm text-muted-foreground\">\n            No models available.\n          </p>\n        ) : (\n          models.map((model, index) => {\n            const selected = model.value === currentValue;\n            const active = model.value === activeValue;\n            const text = optionText(model);\n            return (\n              <motion.button\n                key={model.value}\n                id={`${baseId}-option-${index}`}\n                type=\"button\"\n                role=\"option\"\n                tabIndex={active ? 0 : -1}\n                aria-selected={selected}\n                aria-label={text}\n                disabled={model.disabled}\n                data-active={active ? \"true\" : \"false\"}\n                data-selected={selected ? \"true\" : \"false\"}\n                onFocus={() => {\n                  if (!model.disabled) moveTo(model.value);\n                }}\n                onPointerMove={() => {\n                  if (!model.disabled) moveTo(model.value);\n                }}\n                onKeyDown={(event) => handleModelKeyDown(event, model.value)}\n                onClick={() => choose(model.value)}\n                className=\"relative flex min-h-8 w-full items-center gap-2 rounded-xl px-2.5 py-1 text-left outline-none disabled:pointer-events-none disabled:opacity-40\"\n              >\n                {active ? (\n                  <motion.span\n                    layoutId={`${baseId}-active-row`}\n                    aria-hidden=\"true\"\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                    className=\"absolute inset-0 rounded-xl bg-background\"\n                  />\n                ) : null}\n                {model.icon ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"relative grid size-7 shrink-0 place-items-center rounded-lg border border-border bg-background text-muted-foreground [&_svg]:size-3.5\"\n                  >\n                    {model.icon}\n                  </span>\n                ) : null}\n                <span className=\"relative min-w-0 flex-1\">\n                  <span className=\"flex min-w-0 items-center gap-2\">\n                    <span className=\"truncate text-sm font-medium text-foreground\">\n                      {model.label}\n                    </span>\n                    {model.badge ? (\n                      <span className=\"shrink-0 rounded-md bg-background px-1.5 py-0.5 text-[0.625rem] font-medium text-muted-foreground\">\n                        {model.badge}\n                      </span>\n                    ) : null}\n                  </span>\n                  {model.description ? (\n                    <span className=\"mt-0.5 block truncate text-xs leading-4 text-muted-foreground\">\n                      {model.description}\n                    </span>\n                  ) : null}\n                </span>\n                <span className=\"relative grid size-5 shrink-0 place-items-center text-foreground\">\n                  <AnimatePresence initial={false}>\n                    {selected ? (\n                      <motion.span\n                        initial={\n                          reduce\n                            ? { opacity: 0 }\n                            : { opacity: 0, scale: 0.5, rotate: -18 }\n                        }\n                        animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                        exit={\n                          reduce\n                            ? { opacity: 0 }\n                            : { opacity: 0, scale: 0.65, rotate: 12 }\n                        }\n                        transition={reduce ? { duration: 0.1 } : SPRING_SWAP}\n                      >\n                        <Check className=\"size-3.5\" />\n                      </motion.span>\n                    ) : null}\n                  </AnimatePresence>\n                </span>\n              </motion.button>\n            );\n          })\n        )}\n          </motion.div>\n        </motion.div>\n      )}\n    </PresenceGate>\n  );\n\n  const panel = portalReady\n    ? createPortal(\n        <AnimatePresence>\n          {open ? (\n            <PresenceGate>\n              {({ gate }) => (\n                <motion.div\n                  data-model-selector-portal=\"\"\n                  data-side={placement}\n                  inert={gate.inert}\n                  initial={\n                    reduce\n                      ? { opacity: 0 }\n                      : {\n                          opacity: 0,\n                          y: placement === \"top\" ? 7 : -7,\n                          scale: 0.97,\n                        }\n                  }\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={\n                    reduce\n                      ? { opacity: 0 }\n                      : {\n                          opacity: 0,\n                          y: placement === \"top\" ? 4 : -4,\n                          scale: 0.985,\n                        }\n                  }\n                  transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n                  style={{\n                    ...gate.style,\n                    left: panelLeft,\n                    top: panelAnchorTop,\n                    visibility: triggerRect ? \"visible\" : \"hidden\",\n                    transformOrigin: \"0 0\",\n                  }}\n                  className=\"fixed z-[9999] size-0\"\n                >\n                  <div\n                    ref={panelRef}\n                    id={panelId}\n                    role=\"dialog\"\n                    aria-label={label}\n                    className={cn(\n                      \"absolute left-0 w-[min(17rem,calc(100vw-1.5rem))] rounded-2xl text-popover-foreground\",\n                      placement === \"top\" ? \"bottom-0\" : \"top-0\",\n                      contentClassName,\n                    )}\n                  >\n                    <motion.div\n                      aria-hidden=\"true\"\n                      layout={reduce ? undefined : true}\n                      layoutDependency={view}\n                      transition={{ layout: SPRING_PANEL }}\n                      className=\"pointer-events-none absolute inset-0 rounded-2xl border border-border bg-popover shadow-[0_18px_50px_rgba(0,0,0,0.16)]\"\n                    />\n                    {view === \"effort\" ? effortView : modelView}\n                  </div>\n                </motion.div>\n              )}\n            </PresenceGate>\n          ) : null}\n        </AnimatePresence>,\n        document.body,\n      )\n    : null;\n\n  return (\n    <>\n      <div ref={anchorRef} className={cn(\"relative inline-flex\", className)}>\n        <motion.button\n          ref={triggerRef}\n          id={triggerId}\n          type=\"button\"\n          disabled={disabled}\n          aria-label={`${label}: ${currentText ?? \"None selected\"}, ${currentEffort?.label ?? \"automatic\"} effort`}\n          aria-haspopup=\"dialog\"\n          aria-expanded={open}\n          aria-controls={panelId}\n          onClick={(event) => {\n            const next = !open;\n            setOpen(next);\n            if (next && event.detail === 0) {\n              focusModelButton.current = true;\n            }\n          }}\n          onKeyDown={handleTriggerKeyDown}\n          whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n          transition={SPRING_SWAP}\n          className={cn(\n            \"group flex h-8 min-w-40 max-w-56 items-center gap-1.5 rounded-xl px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n            open && \"bg-muted text-foreground\",\n            triggerClassName,\n          )}\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden\"\n          >\n            {currentModel?.icon ? (\n              <span\n                aria-hidden=\"true\"\n                className=\"grid size-4 shrink-0 place-items-center [&_svg]:size-3.5\"\n              >\n                {currentModel.icon}\n              </span>\n            ) : null}\n            <span className=\"min-w-0 flex-1 truncate text-left\">\n              {currentModel?.label ?? placeholder}\n            </span>\n            {currentEffort ? (\n              <span className=\"w-12 shrink-0 text-left text-muted-foreground/65\">\n                {currentEffort.label}\n              </span>\n            ) : null}\n          </span>\n          <motion.span\n            aria-hidden=\"true\"\n            animate={{ rotate: open ? 180 : 0 }}\n            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n            className=\"shrink-0 text-muted-foreground/70\"\n          >\n            <ChevronDown className=\"size-3.5\" />\n          </motion.span>\n        </motion.button>\n      </div>\n      {panel}\n    </>\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/hooks/use-on-open.ts","type":"registry:hook","target":"@lib/hooks/use-on-open.ts","content":"\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"},{"path":"lib/hooks/use-slider.ts","type":"registry:hook","target":"@lib/hooks/use-slider.ts","content":"\"use client\";\n\nimport { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from \"react\";\nimport { capturePointer, releasePointer } from \"@/lib/touch\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Nearest legal value on [min, max] for the given step. max counts as a\n * candidate when the step does not divide the range, so a pointer near the end\n * does not snap back onto the last whole step. */\nexport function snapSliderValue(next: number, min: number, max: number, step: number): number {\n  // Neither case has a grid to walk. An empty range has exactly one legal\n  // point, and a non-positive step only needs a clamp, which also keeps the\n  // division below away from zero.\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport interface SliderOptions {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  /** Announced instead of the raw number — pass one when the value carries a\n   * unit or a suffix (\"72.5 kg\", \"35%\"); a bare number needs no valueText. */\n  formatValueText?: (value: number) => string;\n}\n\n/**\n * Shared value + input plumbing for slider designs: controlled/uncontrolled\n * value, step snapping, pointer-capture drag along a track and arrow-key\n * control. Visuals and motion live in the component; this only owns the number.\n */\nexport function useSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  formatValueText,\n}: SliderOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLElement | null>(null);\n  // The state drives visuals. Move reads this ref instead, so the first\n  // pointermove after pointerdown does not have to wait on a re-render.\n  const draggingRef = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const controlled = value !== undefined;\n  // Collapse inverted or empty ranges and non-positive steps here, so that\n  // percent, ticks and the keyboard maths never divide by zero or walk a\n  // NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, onValueChange, lo, hi, stride],\n  );\n\n  const commitFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      commit(lo + ratio * (hi - lo));\n    },\n    [commit, lo, hi],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      // Start the drag first: capture is a convenience, and a browser that\n      // refuses it — or a test DOM that has no pointer capture at all — must\n      // not take the drag down with it.\n      draggingRef.current = true;\n      setDragging(true);\n      capturePointer(event.currentTarget, event.pointerId);\n      // A click on the track should land keyboard focus on the handle.\n      sliderEl.current?.focus({ preventScroll: true });\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current || disabled) return;\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    releasePointer(event.currentTarget, event.pointerId);\n    draggingRef.current = false;\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + stride,\n        ArrowUp: current + stride,\n        ArrowLeft: current - stride,\n        ArrowDown: current - stride,\n        PageUp: current + stride * 10,\n        PageDown: current - stride * 10,\n        Home: lo,\n        End: hi,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, stride, lo, hi, commit],\n  );\n\n  return {\n    current,\n    percent,\n    dragging,\n    min: lo,\n    max: hi,\n    step: stride,\n    commit,\n    /** Pointer handlers for the track element — drag anywhere on it. */\n    trackProps: {\n      ref: trackRef,\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: endDrag,\n      onPointerCancel: endDrag,\n      onLostPointerCapture: endDrag,\n    },\n    /** ARIA + keyboard props for the focusable slider element. */\n    sliderProps: {\n      // Callback keeps the handle typed across button/div/motion hosts.\n      ref: (node: HTMLElement | null) => {\n        sliderEl.current = node;\n      },\n      role: \"slider\" as const,\n      tabIndex: disabled ? -1 : 0,\n      \"aria-label\": ariaLabel,\n      \"aria-valuemin\": lo,\n      \"aria-valuemax\": hi,\n      \"aria-valuenow\": current,\n      \"aria-valuetext\": formatValueText?.(current),\n      \"aria-disabled\": disabled || undefined,\n      onKeyDown,\n    },\n  };\n}\n"},{"path":"lib/presence-gate.tsx","type":"registry:lib","target":"@lib/presence-gate.tsx","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * 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"}]}