{"slug":"prompt-input","name":"Prompt Input","description":"A chat composer with attachments, voice and stop.","group":"AI agents","detail_url":"https://beui.dev/native/r/prompt-input","page_url":"https://beui.dev/react-native/prompt-input","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/prompt-input?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/prompt-input","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-svg","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/prompt-input/index.ts","content":"export type {\n  PromptAction,\n  PromptAttachment,\n  PromptInputProps,\n  PromptModel,\n} from \"./prompt-input\";\nexport { PromptInput } from \"./prompt-input\";\n","type":"component"},{"path":"src/components/prompt-input/prompt-input.tsx","content":"import { type ReactNode, type Ref, useEffect, useRef, useState } from \"react\";\nimport {\n  Pressable,\n  StyleSheet,\n  Text,\n  TextInput,\n  type TextInputProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  FadeIn,\n  LinearTransition,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  ArrowUpGlyph,\n  CrossGlyph,\n  MicGlyph,\n  PlusGlyph,\n  StopGlyph,\n} from \"../../primitives/agent-icons\";\nimport { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { RiseIn } from \"../../primitives/rise-in\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"../../styles/motion\";\nimport {\n  Menu,\n  MenuContent,\n  MenuItem,\n  MenuLabel,\n  MenuRadioGroup,\n  MenuRadioItem,\n  MenuSeparator,\n  MenuTrigger,\n} from \"../menu/menu\";\n\nexport type PromptModel = {\n  value: string;\n  label: string;\n  description?: string;\n  icon?: IconSource;\n  disabled?: boolean;\n};\n\nexport type PromptAction = {\n  value: string;\n  label: string;\n  description?: string;\n  icon?: IconSource;\n  disabled?: boolean;\n};\n\nexport type PromptAttachment = {\n  id: string;\n  label: string;\n  /** A thumbnail or file icon. */\n  icon?: IconSource;\n};\n\nconst LINE_HEIGHT = 22;\nconst FIELD_PADDING = 8;\nconst BUTTON = 36;\nconst SURFACE_PADDING = 6;\nconst INLINE_INSET = 6;\nconst WIDE_INSET = 10;\nconst GLIDE = LinearTransition.springify()\n  .stiffness(SPRING_LAYOUT.stiffness)\n  .damping(SPRING_LAYOUT.damping)\n  .mass(SPRING_LAYOUT.mass);\n\n/** Progress runs 0 (arriving) → 1 (resting) → 2 (leaving), with a small scale. */\nfunction SwapIcon({\n  appear,\n  exiting,\n  reduce,\n  children,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const progress = useSharedValue(appear && !reduce ? 0 : 1);\n  useEffect(() => {\n    const target = exiting ? 2 : 1;\n    progress.value = reduce ? target : withSpring(target, SPRING_SWAP);\n  }, [exiting, progress, reduce]);\n  const style = useAnimatedStyle(() => {\n    const away = Math.abs(1 - progress.value);\n    return {\n      opacity: Math.max(0, 1 - away),\n      transform: reduce ? [] : [{ scale: 1 - away * 0.4 }],\n    };\n  }, [reduce]);\n  return (\n    <Animated.View style={[styles.layer, style]}>{children}</Animated.View>\n  );\n}\n\nfunction RoundButton({\n  label,\n  disabled,\n  onPress,\n  className,\n  reduce,\n  children,\n}: {\n  label: string;\n  disabled?: boolean;\n  onPress?: () => void;\n  className: string;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const scale = useSharedValue(1);\n  const style = useAnimatedStyle(() => ({\n    transform: [{ scale: scale.value }],\n  }));\n  return (\n    <Pressable\n      accessibilityRole=\"button\"\n      accessibilityLabel={label}\n      accessibilityState={{ disabled }}\n      disabled={disabled}\n      hitSlop={6}\n      onPress={onPress}\n      onPressIn={() => {\n        if (!reduce) scale.value = withSpring(0.9, SPRING_PRESS);\n      }}\n      onPressOut={() => {\n        scale.value = withSpring(1, SPRING_PRESS);\n      }}\n    >\n      <Animated.View style={[styles.round, style]} className={className}>\n        {children}\n      </Animated.View>\n    </Pressable>\n  );\n}\n\nfunction AttachmentChip({\n  attachment,\n  reduce,\n  onRemove,\n}: {\n  attachment: PromptAttachment;\n  reduce: boolean;\n  onRemove?: (id: string) => void;\n}) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  return (\n    <RiseIn reduce={reduce}>\n      <View\n        className=\"h-9 flex-row items-center gap-2 rounded-xl bg-background pl-2.5 pr-1.5\"\n        style={styles.chip}\n      >\n        {attachment.icon\n          ? renderIcon(attachment.icon, { size: 16, color: muted })\n          : null}\n        <Text\n          numberOfLines={1}\n          className=\"max-w-40 text-[13px] text-foreground\"\n        >\n          {attachment.label}\n        </Text>\n        {onRemove ? (\n          <Pressable\n            accessibilityRole=\"button\"\n            accessibilityLabel={`Remove ${attachment.label}`}\n            hitSlop={8}\n            onPress={() => onRemove(attachment.id)}\n            className=\"size-6 items-center justify-center rounded-full active:bg-surface\"\n          >\n            <CrossGlyph size={12} color={muted} />\n          </Pressable>\n        ) : null}\n      </View>\n    </RiseIn>\n  );\n}\n\nexport type PromptInputProps = Omit<\n  TextInputProps,\n  \"value\" | \"defaultValue\" | \"onChangeText\" | \"onSubmitEditing\" | \"children\"\n> & {\n  value?: string;\n  /** Defaults to \"\". */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Models to choose from, listed in the + menu. */\n  models?: PromptModel[];\n  model?: string;\n  /** Defaults to the first model. */\n  defaultModel?: string;\n  onModelChange?: (model: string) => void;\n  /** Things to add to the prompt, listed in the + menu. */\n  actions?: PromptAction[];\n  onAction?: (action: string) => void;\n  /** Files and images added to the prompt, shown as chips above the text. */\n  attachments?: PromptAttachment[];\n  onRemoveAttachment?: (id: string) => void;\n  /** Shows a mic while the field is empty, such as to start dictation or voice mode. */\n  onVoice?: () => void;\n  /** Called with the trimmed prompt and the selected model. Clears the field when uncontrolled. */\n  onSubmit?: (value: string, model?: string) => void | Promise<void>;\n  /** While true, the send button becomes a stop button. */\n  loading?: boolean;\n  onStop?: () => void;\n  /** Puts the last prompt back in an empty field when you stop a reply. Defaults to true. */\n  restoreOnStop?: boolean;\n  /** Lines the field grows to before scrolling. Defaults to 6. */\n  maxRows?: number;\n  /** Extra controls beside the + button once the composer opens up. */\n  leadingAction?: ReactNode;\n  disabled?: boolean;\n  /** Classes for the composer surface. */\n  className?: string;\n  disableAnimation?: boolean;\n  ref?: Ref<TextInput>;\n};\n\n/**\n * A chat composer in the style of ChatGPT: a soft pill with a + menu, the\n * field, and a mic that turns into a send arrow as you type. When the text\n * wraps, the pill opens into a card with the buttons in a row underneath.\n * Attachments ride above the text as removable chips.\n */\nexport function PromptInput({\n  value: controlledValue,\n  defaultValue = \"\",\n  onValueChange,\n  models = [],\n  model: controlledModel,\n  defaultModel,\n  onModelChange,\n  actions = [],\n  onAction,\n  attachments = [],\n  onRemoveAttachment,\n  onVoice,\n  onSubmit,\n  loading = false,\n  onStop,\n  restoreOnStop = true,\n  maxRows = 6,\n  leadingAction,\n  disabled = false,\n  className = \"\",\n  disableAnimation = false,\n  placeholder = \"Ask anything\",\n  accessibilityLabel = \"Prompt\",\n  ref,\n  ...inputProps\n}: PromptInputProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const value = controlledValue ?? internalValue;\n  const [internalModel, setInternalModel] = useState(\n    defaultModel ?? models[0]?.value,\n  );\n  const model = controlledModel ?? internalModel;\n  const [width, setWidth] = useState(0);\n  // Wrapped heights of the text at the pill's field width and at full width,\n  // measured off-screen so the layout never feeds back into its own choice.\n  const [inlineHeight, setInlineHeight] = useState(LINE_HEIGHT);\n  const [wideHeight, setWideHeight] = useState(LINE_HEIGHT);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const lastPrompt = useRef(\"\");\n  const turn = useSharedValue(0);\n  const plusScale = useSharedValue(1);\n  const { color: onPrimary } = useResolveClassNames(\"text-on-primary\");\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n\n  const trimmed = value.trim();\n  const canSubmit =\n    (trimmed.length > 0 || attachments.length > 0) && !disabled && !loading;\n  const hasMenu = actions.length > 0 || models.length > 0;\n  // One line sits in the pill; a second line opens it into a card.\n  const multiline = inlineHeight > LINE_HEIGHT * 1.5;\n  const expanded = multiline || attachments.length > 0;\n  const contentHeight = expanded ? wideHeight : inlineHeight;\n  const fieldHeight = Math.min(\n    maxRows * LINE_HEIGHT,\n    Math.max(LINE_HEIGHT, contentHeight),\n  );\n  const inner = Math.max(0, width - SURFACE_PADDING * 2);\n  const inlineWidth =\n    inner - (hasMenu ? BUTTON : 0) - BUTTON - 4 - INLINE_INSET * 2;\n  const wideWidth = inner - WIDE_INSET * 2;\n  // A zero-width space keeps a trailing newline's line in the measurement.\n  const mirror = `${value}\\u200b`;\n  const selectedModel = models.find((entry) => entry.value === model);\n\n  useEffect(() => {\n    turn.value = reduce\n      ? menuOpen\n        ? 1\n        : 0\n      : withSpring(menuOpen ? 1 : 0, SPRING_SWAP);\n  }, [menuOpen, reduce, turn]);\n  const plusPressStyle = useAnimatedStyle(() => ({\n    transform: [{ scale: plusScale.value }],\n  }));\n  const plusStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 45}deg` }],\n  }));\n\n  const setValue = (next: string) => {\n    if (controlledValue === undefined) setInternalValue(next);\n    onValueChange?.(next);\n  };\n\n  const submit = () => {\n    if (!canSubmit) return;\n    lastPrompt.current = trimmed;\n    void onSubmit?.(trimmed, model);\n    if (controlledValue === undefined) setInternalValue(\"\");\n  };\n\n  const stop = () => {\n    onStop?.();\n    if (restoreOnStop && value.trim() === \"\" && lastPrompt.current)\n      setValue(lastPrompt.current);\n  };\n\n  // The trailing button: stop while loading, send once there's something to\n  // send, otherwise the mic when there's a voice handler.\n  const trailing: \"stop\" | \"send\" | \"voice\" = loading\n    ? \"stop\"\n    : canSubmit || !onVoice\n      ? \"send\"\n      : \"voice\";\n  const trailingIcons = usePresence(trailing, trailing, () =>\n    reduce ? 0 : 240,\n  );\n  const filled = trailing === \"stop\" || (trailing === \"send\" && canSubmit);\n\n  const plus = hasMenu ? (\n    <Animated.View layout={reduce ? undefined : GLIDE}>\n      <Menu\n        open={menuOpen}\n        onOpenChange={setMenuOpen}\n        disableAnimation={disableAnimation}\n      >\n        <MenuTrigger\n          accessibilityLabel={\n            selectedModel\n              ? `Add to prompt, model ${selectedModel.label}`\n              : \"Add to prompt\"\n          }\n          disabled={disabled}\n          onPressIn={() => {\n            if (!reduce) plusScale.value = withSpring(0.9, SPRING_PRESS);\n          }}\n          onPressOut={() => {\n            plusScale.value = withSpring(1, SPRING_PRESS);\n          }}\n        >\n          <Animated.View\n            style={[styles.round, plusPressStyle]}\n            className={menuOpen ? \"bg-foreground/10\" : \"\"}\n          >\n            <Animated.View style={plusStyle}>\n              <PlusGlyph size={20} color={foreground} strokeWidth={1.8} />\n            </Animated.View>\n          </Animated.View>\n        </MenuTrigger>\n        <MenuContent align=\"start\">\n          {actions.map((action) => (\n            <MenuItem\n              key={action.value}\n              icon={action.icon}\n              description={action.description}\n              disabled={action.disabled}\n              onSelect={() => onAction?.(action.value)}\n            >\n              {action.label}\n            </MenuItem>\n          ))}\n          {actions.length > 0 && models.length > 0 ? <MenuSeparator /> : null}\n          {models.length > 0 ? (\n            <>\n              <MenuLabel>Model</MenuLabel>\n              <MenuRadioGroup\n                value={model}\n                onValueChange={(next) => {\n                  if (controlledModel === undefined) setInternalModel(next);\n                  onModelChange?.(next);\n                }}\n              >\n                {models.map((entry) => (\n                  <MenuRadioItem\n                    key={entry.value}\n                    value={entry.value}\n                    icon={entry.icon}\n                    description={entry.description}\n                    disabled={entry.disabled}\n                  >\n                    {entry.label}\n                  </MenuRadioItem>\n                ))}\n              </MenuRadioGroup>\n            </>\n          ) : null}\n        </MenuContent>\n      </Menu>\n    </Animated.View>\n  ) : null;\n\n  const send = (\n    <Animated.View layout={reduce ? undefined : GLIDE}>\n      <RoundButton\n        label={\n          trailing === \"stop\"\n            ? \"Stop generating\"\n            : trailing === \"voice\"\n              ? \"Voice\"\n              : \"Send prompt\"\n        }\n        disabled={\n          trailing === \"stop\"\n            ? !onStop\n            : trailing === \"send\"\n              ? !canSubmit\n              : disabled\n        }\n        reduce={reduce}\n        onPress={\n          trailing === \"stop\" ? stop : trailing === \"voice\" ? onVoice : submit\n        }\n        className={\n          filled ? \"bg-primary\" : trailing === \"voice\" ? \"\" : \"bg-foreground/10\"\n        }\n      >\n        {trailingIcons.map((item) => (\n          <SwapIcon\n            key={item.key}\n            appear={item.appear}\n            exiting={item.exiting}\n            reduce={reduce}\n          >\n            {item.value === \"stop\" ? (\n              <StopGlyph size={12} color={onPrimary} />\n            ) : item.value === \"voice\" ? (\n              <MicGlyph size={20} color={foreground} strokeWidth={1.8} />\n            ) : (\n              <ArrowUpGlyph\n                size={18}\n                color={canSubmit ? onPrimary : muted}\n                strokeWidth={2.25}\n              />\n            )}\n          </SwapIcon>\n        ))}\n      </RoundButton>\n    </Animated.View>\n  );\n\n  const field = (\n    <TextInput\n      ref={ref}\n      multiline\n      value={value}\n      onChangeText={setValue}\n      editable={!disabled}\n      placeholder={placeholder}\n      accessibilityLabel={accessibilityLabel}\n      scrollEnabled={contentHeight > maxRows * LINE_HEIGHT}\n      placeholderTextColor={muted}\n      cursorColor={foreground}\n      selectionColor={foreground}\n      className=\"text-[16px] text-foreground\"\n      style={[\n        styles.field,\n        { height: fieldHeight + FIELD_PADDING * 2 },\n        expanded ? styles.fieldWide : styles.fieldInline,\n      ]}\n      {...inputProps}\n    />\n  );\n\n  return (\n    <Animated.View\n      layout={reduce ? undefined : GLIDE}\n      onLayout={({ nativeEvent }) => setWidth(nativeEvent.layout.width)}\n      style={[styles.surface, expanded ? styles.card : styles.pill]}\n      className={`bg-surface ${disabled ? \"opacity-60\" : \"\"} ${className}`}\n    >\n      {attachments.length > 0 ? (\n        <View style={styles.attachments}>\n          {attachments.map((attachment) => (\n            <AttachmentChip\n              key={attachment.id}\n              attachment={attachment}\n              reduce={reduce}\n              onRemove={onRemoveAttachment}\n            />\n          ))}\n        </View>\n      ) : null}\n      {/* The field keeps one place in the tree, so it never loses focus as the shape changes. */}\n      <View style={expanded ? undefined : styles.row}>\n        {expanded ? null : plus}\n        {field}\n        {expanded ? null : send}\n      </View>\n      {expanded ? (\n        <Animated.View\n          entering={reduce ? undefined : FadeIn.duration(160)}\n          style={styles.toolbar}\n        >\n          {plus}\n          {leadingAction}\n          <View style={styles.spacer} />\n          {send}\n        </Animated.View>\n      ) : null}\n      {width > 0 ? (\n        <View\n          pointerEvents=\"none\"\n          style={styles.mirrors}\n          importantForAccessibility=\"no-hide-descendants\"\n        >\n          <Text\n            className=\"text-[16px]\"\n            style={[styles.mirror, { width: inlineWidth }]}\n            onLayout={({ nativeEvent }) =>\n              setInlineHeight(nativeEvent.layout.height)\n            }\n          >\n            {mirror}\n          </Text>\n          <Text\n            className=\"text-[16px]\"\n            style={[styles.mirror, { width: wideWidth }]}\n            onLayout={({ nativeEvent }) =>\n              setWideHeight(nativeEvent.layout.height)\n            }\n          >\n            {mirror}\n          </Text>\n        </View>\n      ) : null}\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  surface: { overflow: \"hidden\", padding: SURFACE_PADDING },\n  pill: { borderRadius: 28 },\n  card: { borderRadius: 24 },\n  row: { flexDirection: \"row\", alignItems: \"flex-end\", gap: 2 },\n  field: {\n    lineHeight: LINE_HEIGHT,\n    paddingTop: FIELD_PADDING,\n    paddingBottom: FIELD_PADDING,\n    textAlignVertical: \"top\",\n  },\n  fieldInline: { flex: 1, paddingHorizontal: INLINE_INSET },\n  fieldWide: { paddingHorizontal: WIDE_INSET },\n  mirrors: { position: \"absolute\", left: 0, top: 0, opacity: 0 },\n  mirror: { position: \"absolute\", left: 0, top: 0, lineHeight: LINE_HEIGHT },\n  toolbar: { flexDirection: \"row\", alignItems: \"center\", gap: 2, marginTop: 2 },\n  spacer: { flex: 1 },\n  attachments: {\n    flexDirection: \"row\",\n    flexWrap: \"wrap\",\n    gap: 6,\n    padding: 4,\n    paddingBottom: 2,\n  },\n  chip: {\n    shadowColor: \"#000\",\n    shadowOpacity: 0.05,\n    shadowRadius: 4,\n    shadowOffset: { width: 0, height: 1 },\n    elevation: 1,\n  },\n  round: {\n    width: BUTTON,\n    height: BUTTON,\n    borderRadius: BUTTON / 2,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  layer: { position: \"absolute\" },\n});\n","type":"component"},{"path":"src/components/menu/menu.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Modal,\n  Pressable,\n  type PressableProps,\n  ScrollView,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  LinearTransition,\n  ReduceMotion,\n  type SharedValue,\n  useAnimatedReaction,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { CheckGlyph, ChevronDownGlyph } from \"../../primitives/agent-icons\";\nimport { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"../../styles/motion\";\n\nconst EDGE = 12;\nconst GAP = 8;\nconst TOP_CLEARANCE = 56;\nconst STAGGER = 24;\n// The panel's height glides as a submenu replaces the items.\nconst PANEL_RESIZE = LinearTransition.springify()\n  .stiffness(SPRING_LAYOUT.stiffness)\n  .damping(SPRING_LAYOUT.damping)\n  .mass(SPRING_LAYOUT.mass);\nconst SPRING_OPEN = {\n  stiffness: 380,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n};\nconst CLOSE = { duration: 150, easing: EASE_OUT };\n\ntype Frame = { x: number; y: number; width: number; height: number };\ntype Slot = { top: number; height: number; disabled: boolean };\ntype Entry = { slot: Slot; select: () => void; closes: boolean };\n\ntype MenuContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  anchor: Frame | null;\n  triggerRef: React.RefObject<View | null>;\n  reduce: boolean;\n  /** Item frames in layout order, read on the UI thread while sliding. */\n  slots: SharedValue<Slot[]>;\n  highlight: SharedValue<number>;\n  register: (id: string, entry: Entry | null) => void;\n  selectAt: (index: number) => void;\n  /** Order in which items mount, for the entrance stagger. */\n  order: (id: string) => number;\n  /** The page on show: the root list, or a submenu's id. */\n  page: string;\n  /** The open submenus, outermost first. */\n  stack: { id: string; label: string }[];\n  /** 1 after opening a submenu, -1 after going back. */\n  direction: number;\n  push: (id: string, label: string) => void;\n  back: () => void;\n};\n\nconst ROOT = \"root\";\n/** Which page an item belongs to. */\nconst PageContext = createContext(ROOT);\n\n/** True when the item's page is the one on show. */\nfunction useOnPage() {\n  const page = useContext(PageContext);\n  const context = useContext(MenuContext);\n  return context ? context.page === page : false;\n}\n\nconst MenuContext = createContext<MenuContextValue | null>(null);\n\nfunction useMenuContext(part: string) {\n  const context = useContext(MenuContext);\n  if (!context) throw new Error(`${part} must be used within <Menu>`);\n  return context;\n}\n\n/** Open state for custom triggers and items. */\nexport function useMenu() {\n  const { open, setOpen } = useMenuContext(\"useMenu\");\n  return { open, setOpen, close: () => setOpen(false) };\n}\n\nexport type MenuProps = {\n  open?: boolean;\n  /** Defaults to false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Opens and closes without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n  children: ReactNode;\n};\n\n/**\n * A pull-down menu: a trigger and the actions it reveals. Compose MenuTrigger\n * and MenuContent with MenuItem, MenuLabel, MenuSeparator, MenuRadioGroup and\n * MenuCheckboxItem inside.\n */\nexport function Menu({\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  disableAnimation = false,\n  children,\n}: MenuProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? internalOpen;\n  const [anchor, setAnchor] = useState<Frame | null>(null);\n  const triggerRef = useRef<View>(null);\n  const slots = useSharedValue<Slot[]>([]);\n  const highlight = useSharedValue(-1);\n  const entries = useRef(new Map<string, Entry>());\n  const sorted = useRef<Entry[]>([]);\n  const mountOrder = useRef(new Map<string, number>());\n  const [stack, setStack] = useState<{ id: string; label: string }[]>([]);\n  const [direction, setDirection] = useState(1);\n  const page = stack[stack.length - 1]?.id ?? ROOT;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      const apply = () => {\n        if (controlledOpen === undefined) setInternalOpen(next);\n        onOpenChange?.(next);\n      };\n      if (next) {\n        // Measure the trigger first, so the menu opens from where it is now.\n        triggerRef.current?.measureInWindow((x, y, width, height) => {\n          setAnchor({ x, y, width, height });\n          apply();\n        });\n        if (!triggerRef.current) apply();\n      } else {\n        apply();\n      }\n    },\n    [controlledOpen, onOpenChange],\n  );\n\n  useEffect(() => {\n    if (!open) {\n      highlight.value = -1;\n      mountOrder.current.clear();\n      setStack([]);\n    }\n  }, [highlight, open]);\n\n  const push = useCallback((id: string, label: string) => {\n    mountOrder.current.clear();\n    setDirection(1);\n    setStack((current) => [...current, { id, label }]);\n  }, []);\n  const back = useCallback(() => {\n    mountOrder.current.clear();\n    setDirection(-1);\n    setStack((current) => current.slice(0, -1));\n  }, []);\n\n  // A controlled open without a measured trigger still needs its anchor.\n  useEffect(() => {\n    if (!open || anchor) return;\n    triggerRef.current?.measureInWindow((x, y, width, height) =>\n      setAnchor({ x, y, width, height }),\n    );\n  }, [anchor, open]);\n\n  const register = useCallback(\n    (id: string, entry: Entry | null) => {\n      if (entry) entries.current.set(id, entry);\n      else entries.current.delete(id);\n      sorted.current = [...entries.current.values()].sort(\n        (a, b) => a.slot.top - b.slot.top,\n      );\n      slots.value = sorted.current.map((item) => item.slot);\n    },\n    [slots],\n  );\n\n  const selectAt = useCallback(\n    (index: number) => {\n      const entry = sorted.current[index];\n      if (!entry || entry.slot.disabled) return;\n      entry.select();\n      if (entry.closes) setOpen(false);\n    },\n    [setOpen],\n  );\n\n  const order = useCallback((id: string) => {\n    const map = mountOrder.current;\n    if (!map.has(id)) map.set(id, map.size);\n    return map.get(id) ?? 0;\n  }, []);\n\n  const value = useMemo(\n    () => ({\n      open,\n      setOpen,\n      anchor,\n      triggerRef,\n      reduce,\n      slots,\n      highlight,\n      register,\n      selectAt,\n      order,\n      page,\n      stack,\n      direction,\n      push,\n      back,\n    }),\n    [\n      back,\n      direction,\n      page,\n      push,\n      stack,\n      anchor,\n      highlight,\n      open,\n      order,\n      reduce,\n      register,\n      selectAt,\n      setOpen,\n      slots,\n    ],\n  );\n\n  return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>;\n}\n\nexport type MenuTriggerProps = Omit<PressableProps, \"onPress\" | \"children\"> & {\n  children: ReactNode;\n};\n\n/** Opens the menu. Wraps your button content; it reports whether the menu is open. */\nexport function MenuTrigger({\n  children,\n  accessibilityLabel,\n  ...props\n}: MenuTriggerProps) {\n  const { open, setOpen, triggerRef } = useMenuContext(\"MenuTrigger\");\n  return (\n    <View ref={triggerRef} collapsable={false}>\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityLabel={accessibilityLabel}\n        accessibilityState={{ expanded: open }}\n        hitSlop={6}\n        onPress={() => setOpen(!open)}\n        {...props}\n      >\n        {children}\n      </Pressable>\n    </View>\n  );\n}\n\nconst SectionContext = createContext({\n  radio: undefined as RadioContextValue | undefined,\n});\n\n/** Each row fades down into place, a beat after the one above. */\nfunction useEntrance(index: number) {\n  const { open, reduce } = useMenuContext(\"Menu items\");\n  const shown = useSharedValue(0);\n  useEffect(() => {\n    shown.value = open\n      ? withDelay(\n          reduce ? 0 : 50 + index * STAGGER,\n          withTiming(1, { duration: 160, easing: EASE_OUT }),\n        )\n      : withTiming(0, { duration: 90 });\n  }, [index, open, reduce, shown]);\n  return useAnimatedStyle(() => ({\n    opacity: shown.value,\n    transform: [{ translateY: reduce ? 0 : (1 - shown.value) * -6 }],\n  }));\n}\n\nfunction useSlot({\n  disabled,\n  select,\n  closes,\n}: {\n  disabled: boolean;\n  select: () => void;\n  closes: boolean;\n}) {\n  const { register, slots, highlight, order } = useMenuContext(\"Menu items\");\n  const id = useId();\n  const latest = useRef({ select, closes, disabled });\n  latest.current = { select, closes, disabled };\n  const frame = useRef<{ top: number; height: number } | null>(null);\n  const index = order(id);\n\n  const sync = useCallback(() => {\n    if (!frame.current) return;\n    register(id, {\n      slot: { ...frame.current, disabled: latest.current.disabled },\n      select: () => latest.current.select(),\n      closes: latest.current.closes,\n    });\n  }, [id, register]);\n\n  useEffect(() => {\n    sync();\n  }, [disabled, closes, sync]);\n  useEffect(() => () => register(id, null), [id, register]);\n\n  const onLayout = (event: {\n    nativeEvent: { layout: { y: number; height: number } };\n  }) => {\n    frame.current = {\n      top: event.nativeEvent.layout.y,\n      height: event.nativeEvent.layout.height,\n    };\n    sync();\n  };\n\n  const style = useEntrance(index);\n\n  return { onLayout, style, slots, highlight };\n}\n\ntype ItemBodyProps = {\n  icon?: IconSource;\n  description?: string;\n  destructive?: boolean;\n  disabled?: boolean;\n  trailing?: ReactNode;\n  children: ReactNode;\n};\n\nfunction ItemBody({\n  icon,\n  description,\n  destructive,\n  disabled,\n  trailing,\n  children,\n}: ItemBodyProps) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const { color: danger } = useResolveClassNames(\"text-destructive\");\n  return (\n    <View\n      style={[styles.item, disabled && styles.disabled]}\n      className=\"px-3 py-2.5\"\n    >\n      {icon ? (\n        <View style={styles.icon}>\n          {renderIcon(icon, { size: 18, color: destructive ? danger : muted })}\n        </View>\n      ) : null}\n      <View style={styles.flex}>\n        {typeof children === \"string\" ? (\n          <Text\n            numberOfLines={1}\n            className={`text-[15px] ${destructive ? \"text-destructive\" : \"text-foreground\"}`}\n          >\n            {children}\n          </Text>\n        ) : (\n          children\n        )}\n        {description ? (\n          <Text numberOfLines={2} className=\"text-xs leading-4 text-muted\">\n            {description}\n          </Text>\n        ) : null}\n      </View>\n      {trailing}\n    </View>\n  );\n}\n\nexport type MenuItemProps = Omit<ItemBodyProps, \"trailing\"> & {\n  onSelect?: () => void;\n  /** Defaults to true. */\n  closeOnSelect?: boolean;\n  /** Spoken name when the children aren't plain text. */\n  accessibilityLabel?: string;\n};\n\n/** An action. Tap it, or slide onto it and let go. */\nfunction MenuItemRow({\n  onSelect,\n  closeOnSelect = true,\n  disabled = false,\n  accessibilityLabel,\n  ...body\n}: MenuItemProps) {\n  const { setOpen } = useMenuContext(\"MenuItem\");\n  const { onLayout, style } = useSlot({\n    disabled,\n    select: () => onSelect?.(),\n    closes: closeOnSelect,\n  });\n  return (\n    <Animated.View\n      onLayout={onLayout}\n      style={style}\n      accessible\n      accessibilityRole=\"menuitem\"\n      accessibilityLabel={\n        accessibilityLabel ??\n        (typeof body.children === \"string\" ? body.children : undefined)\n      }\n      accessibilityHint={body.description}\n      accessibilityState={{ disabled }}\n      onAccessibilityTap={() => {\n        if (disabled) return;\n        onSelect?.();\n        if (closeOnSelect) setOpen(false);\n      }}\n    >\n      <ItemBody disabled={disabled} {...body} />\n    </Animated.View>\n  );\n}\n\n/** A check that springs in when its item is chosen. */\nfunction Check({ on, reduce }: { on: boolean; reduce: boolean }) {\n  const { color: primary } = useResolveClassNames(\"text-primary\");\n  const shown = useSharedValue(on ? 1 : 0);\n  useEffect(() => {\n    shown.value = reduce ? (on ? 1 : 0) : withSpring(on ? 1 : 0, SPRING_SWAP);\n  }, [on, reduce, shown]);\n  const style = useAnimatedStyle(() => ({\n    opacity: shown.value,\n    transform: [{ scale: 0.4 + shown.value * 0.6 }],\n  }));\n  return (\n    <Animated.View style={[styles.check, style]}>\n      <CheckGlyph size={18} color={primary} strokeWidth={2.25} />\n    </Animated.View>\n  );\n}\n\nexport type MenuCheckboxItemProps = Omit<ItemBodyProps, \"trailing\"> & {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  /** Defaults to false, so several can be toggled in one visit. */\n  closeOnSelect?: boolean;\n};\n\n/** An option that toggles on and off, with a check that springs in. */\nfunction MenuCheckboxItemRow({\n  checked,\n  onCheckedChange,\n  closeOnSelect = false,\n  disabled = false,\n  ...body\n}: MenuCheckboxItemProps) {\n  const { reduce, setOpen } = useMenuContext(\"MenuCheckboxItem\");\n  const { onLayout, style } = useSlot({\n    disabled,\n    select: () => onCheckedChange(!checked),\n    closes: closeOnSelect,\n  });\n  return (\n    <Animated.View\n      onLayout={onLayout}\n      style={style}\n      accessible\n      accessibilityRole=\"checkbox\"\n      accessibilityLabel={\n        typeof body.children === \"string\" ? body.children : undefined\n      }\n      accessibilityState={{ checked, disabled }}\n      onAccessibilityTap={() => {\n        if (disabled) return;\n        onCheckedChange(!checked);\n        if (closeOnSelect) setOpen(false);\n      }}\n    >\n      <ItemBody\n        disabled={disabled}\n        trailing={<Check on={checked} reduce={reduce} />}\n        {...body}\n      />\n    </Animated.View>\n  );\n}\n\ntype RadioContextValue = {\n  value: string | undefined;\n  select: (value: string) => void;\n};\n\nexport type MenuRadioGroupProps = {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  children: ReactNode;\n};\n\n/** A set of options where one is chosen. */\nexport function MenuRadioGroup({\n  value: controlled,\n  defaultValue,\n  onValueChange,\n  children,\n}: MenuRadioGroupProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const value = controlled ?? internal;\n  const radio = useMemo(\n    () => ({\n      value,\n      select: (next: string) => {\n        if (controlled === undefined) setInternal(next);\n        onValueChange?.(next);\n      },\n    }),\n    [controlled, onValueChange, value],\n  );\n  return (\n    <SectionContext.Provider value={{ radio }}>\n      {children}\n    </SectionContext.Provider>\n  );\n}\n\nexport type MenuRadioItemProps = Omit<ItemBodyProps, \"trailing\"> & {\n  value: string;\n  /** Defaults to true. */\n  closeOnSelect?: boolean;\n};\n\n/** One choice in a MenuRadioGroup, checked when it's the group's value. */\nfunction MenuRadioItemRow({\n  value,\n  closeOnSelect = true,\n  disabled = false,\n  ...body\n}: MenuRadioItemProps) {\n  const { reduce, setOpen } = useMenuContext(\"MenuRadioItem\");\n  const { radio } = useContext(SectionContext);\n  if (!radio)\n    throw new Error(\"MenuRadioItem must be used within <MenuRadioGroup>\");\n  const checked = radio.value === value;\n  const { onLayout, style } = useSlot({\n    disabled,\n    select: () => radio.select(value),\n    closes: closeOnSelect,\n  });\n  return (\n    <Animated.View\n      onLayout={onLayout}\n      style={style}\n      accessible\n      accessibilityRole=\"radio\"\n      accessibilityLabel={\n        typeof body.children === \"string\" ? body.children : undefined\n      }\n      accessibilityHint={body.description}\n      accessibilityState={{ checked, disabled }}\n      onAccessibilityTap={() => {\n        if (disabled) return;\n        radio.select(value);\n        if (closeOnSelect) setOpen(false);\n      }}\n    >\n      <ItemBody\n        disabled={disabled}\n        trailing={<Check on={checked} reduce={reduce} />}\n        {...body}\n      />\n    </Animated.View>\n  );\n}\n\n/** An action. Tap it, or slide onto it and let go. */\nexport function MenuItem(props: MenuItemProps) {\n  return useOnPage() ? <MenuItemRow {...props} /> : null;\n}\n\n/** An option that toggles on and off, with a check that springs in. */\nexport function MenuCheckboxItem(props: MenuCheckboxItemProps) {\n  return useOnPage() ? <MenuCheckboxItemRow {...props} /> : null;\n}\n\n/** One choice in a MenuRadioGroup, checked when it's the group's value. */\nexport function MenuRadioItem(props: MenuRadioItemProps) {\n  return useOnPage() ? <MenuRadioItemRow {...props} /> : null;\n}\n\ntype SubContextValue = { id: string };\nconst SubContext = createContext<SubContextValue | null>(null);\n\n/** A submenu: a MenuSubTrigger and the MenuSubContent it opens. */\nexport function MenuSub({ children }: { children: ReactNode }) {\n  const id = useId();\n  const value = useMemo(() => ({ id }), [id]);\n  return <SubContext.Provider value={value}>{children}</SubContext.Provider>;\n}\n\nfunction Chevron({ back = false }: { back?: boolean }) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  return (\n    <View style={{ transform: [{ rotate: back ? \"90deg\" : \"-90deg\" }] }}>\n      <ChevronDownGlyph size={16} color={muted} />\n    </View>\n  );\n}\n\nexport type MenuSubTriggerProps = Omit<\n  ItemBodyProps,\n  \"trailing\" | \"destructive\"\n> & {\n  /** The submenu's title on its back row. Defaults to the text of the trigger. */\n  title?: string;\n};\n\nfunction MenuSubTriggerRow({\n  disabled = false,\n  title,\n  ...body\n}: MenuSubTriggerProps) {\n  const { push } = useMenuContext(\"MenuSubTrigger\");\n  const sub = useContext(SubContext);\n  if (!sub) throw new Error(\"MenuSubTrigger must be used within <MenuSub>\");\n  const label =\n    title ?? (typeof body.children === \"string\" ? body.children : \"Back\");\n  const open = () => push(sub.id, label);\n  const { onLayout, style } = useSlot({\n    disabled,\n    select: open,\n    closes: false,\n  });\n  return (\n    <Animated.View\n      onLayout={onLayout}\n      style={style}\n      accessible\n      accessibilityRole=\"menuitem\"\n      accessibilityLabel={label}\n      accessibilityHint=\"Opens a submenu\"\n      accessibilityState={{ disabled }}\n      onAccessibilityTap={() => {\n        if (!disabled) open();\n      }}\n    >\n      <ItemBody disabled={disabled} trailing={<Chevron />} {...body} />\n    </Animated.View>\n  );\n}\n\n/** A row that opens its submenu in place of the current items. */\nexport function MenuSubTrigger(props: MenuSubTriggerProps) {\n  return useOnPage() ? <MenuSubTriggerRow {...props} /> : null;\n}\n\n/** The back row at the top of a submenu, titled with the submenu's name. */\nfunction BackRow({ label }: { label: string }) {\n  const { back } = useMenuContext(\"MenuSubContent\");\n  const { onLayout, style } = useSlot({\n    disabled: false,\n    select: back,\n    closes: false,\n  });\n  return (\n    <Animated.View\n      onLayout={onLayout}\n      style={style}\n      accessible\n      accessibilityRole=\"button\"\n      accessibilityLabel={`Back from ${label}`}\n      onAccessibilityTap={back}\n    >\n      {/* The chevron's stroke lines up with the item text edge below. */}\n      <View style={[styles.item, styles.backRow]} className=\"px-3 py-2.5\">\n        <View style={styles.backChevron}>\n          <Chevron back />\n        </View>\n        <Text\n          numberOfLines={1}\n          className=\"flex-1 text-[15px] font-semibold text-foreground\"\n        >\n          {label}\n        </Text>\n      </View>\n    </Animated.View>\n  );\n}\n\n/**\n * The items a MenuSubTrigger opens. They replace the current items, with a\n * back row on top; submenus can nest.\n */\nexport function MenuSubContent({ children }: { children: ReactNode }) {\n  const sub = useContext(SubContext);\n  const context = useContext(MenuContext);\n  if (!sub) throw new Error(\"MenuSubContent must be used within <MenuSub>\");\n  const active = context?.page === sub.id;\n  const label =\n    context?.stack.find((entry) => entry.id === sub.id)?.label ?? \"\";\n  return (\n    <PageContext.Provider value={sub.id}>\n      {active ? (\n        <>\n          <BackRow label={label} />\n          <MenuSeparator />\n        </>\n      ) : null}\n      {children}\n    </PageContext.Provider>\n  );\n}\n\n/** A small heading over a group of items. */\nexport function MenuLabel({ children }: { children: string }) {\n  return useOnPage() ? <MenuLabelRow>{children}</MenuLabelRow> : null;\n}\n\nfunction MenuLabelRow({ children }: { children: string }) {\n  const { order } = useMenuContext(\"MenuLabel\");\n  const style = useEntrance(order(useId()));\n  return (\n    <Animated.View style={style}>\n      <Text\n        accessibilityRole=\"header\"\n        className=\"px-3 pb-1 pt-2 text-xs font-medium text-muted\"\n      >\n        {children}\n      </Text>\n    </Animated.View>\n  );\n}\n\n/** A hairline between groups of items. */\nexport function MenuSeparator() {\n  return useOnPage() ? <MenuSeparatorRow /> : null;\n}\n\nfunction MenuSeparatorRow() {\n  const { order } = useMenuContext(\"MenuSeparator\");\n  const style = useEntrance(order(useId()));\n  return (\n    <Animated.View style={style}>\n      <View style={styles.separator} className=\"mx-3 my-1 bg-border-strong\" />\n    </Animated.View>\n  );\n}\n\n/** The pill that glides under your finger from item to item. */\nfunction Highlight({\n  slots,\n  highlight,\n}: {\n  slots: SharedValue<Slot[]>;\n  highlight: SharedValue<number>;\n}) {\n  const top = useSharedValue(0);\n  const height = useSharedValue(0);\n  const opacity = useSharedValue(0);\n  useAnimatedReaction(\n    () => highlight.value,\n    (index) => {\n      const slot = index >= 0 ? slots.value[index] : undefined;\n      if (slot) {\n        // The first touch places it; later moves glide on the layout spring.\n        const fresh = opacity.value < 0.05;\n        top.value = fresh ? slot.top : withSpring(slot.top, SPRING_LAYOUT);\n        height.value = fresh\n          ? slot.height\n          : withSpring(slot.height, SPRING_LAYOUT);\n      }\n      opacity.value = withTiming(slot ? 1 : 0, { duration: slot ? 80 : 140 });\n    },\n  );\n  const style = useAnimatedStyle(() => ({\n    top: top.value,\n    height: height.value,\n    opacity: opacity.value,\n  }));\n  return (\n    <Animated.View\n      pointerEvents=\"none\"\n      style={[styles.highlight, style]}\n      className=\"bg-foreground/5\"\n    />\n  );\n}\n\nconst PAGE_SLIDE = 28;\n\n/**\n * Slides each new page in from the side it's arriving from. It stays mounted\n * across pages, so submenus keep their identity.\n */\nfunction PageSlide({\n  page,\n  direction,\n  reduce,\n  children,\n}: {\n  page: string;\n  direction: number;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const progress = useSharedValue(1);\n  const first = useRef(true);\n  useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (reduce) return;\n    progress.value = 0;\n    progress.value = withSpring(1, SPRING_LAYOUT);\n  }, [page, progress, reduce]);\n  const style = useAnimatedStyle(() => ({\n    opacity: Math.min(1, progress.value * 1.4),\n    transform: [{ translateX: (1 - progress.value) * PAGE_SLIDE * direction }],\n  }));\n  return <Animated.View style={style}>{children}</Animated.View>;\n}\n\nexport type MenuContentProps = {\n  children: ReactNode;\n  /** Which edge lines up with the trigger. Defaults to \"start\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** \"auto\" opens below the trigger, or above when there isn't room. Defaults to \"auto\". */\n  side?: \"auto\" | \"top\" | \"bottom\";\n  /** Defaults to 260. */\n  width?: number;\n  className?: string;\n};\n\n/**\n * The menu panel. It springs out of the corner nearest its trigger, and its\n * rows fade in one after another. Slide a finger across it and a highlight\n * glides from item to item; let go to choose.\n */\nexport function MenuContent({\n  children,\n  align = \"start\",\n  side = \"auto\",\n  width: preferredWidth = 260,\n  className = \"\",\n}: MenuContentProps) {\n  const {\n    open,\n    setOpen,\n    anchor,\n    reduce,\n    slots,\n    highlight,\n    selectAt,\n    page,\n    direction,\n    back,\n    stack,\n  } = useMenuContext(\"MenuContent\");\n  const window = useWindowDimensions();\n  const [present, setPresent] = useState(open);\n  const [contentHeight, setContentHeight] = useState(0);\n  const progress = useSharedValue(0);\n  if (open && !present) setPresent(true);\n  const hide = useCallback(() => setPresent(false), []);\n\n  useEffect(() => {\n    if (!present) return;\n    if (open)\n      progress.value = reduce\n        ? withTiming(1, { duration: 120 })\n        : withSpring(1, SPRING_OPEN);\n    else\n      progress.value = withTiming(\n        0,\n        reduce ? { duration: 120 } : CLOSE,\n        (done) => {\n          if (done) scheduleOnRN(hide);\n        },\n      );\n  }, [hide, open, present, progress, reduce]);\n\n  const panelStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.min(1, p * 1.8),\n      transform: reduce ? [] : [{ scale: 0.55 + Math.max(0, p) * 0.45 }],\n    };\n  }, [reduce]);\n  const scrimStyle = useAnimatedStyle(() => ({\n    opacity: Math.min(1, progress.value),\n  }));\n\n  if (!present || !anchor) return null;\n\n  const width = Math.min(preferredWidth, window.width - EDGE * 2);\n  const below = window.height - (anchor.y + anchor.height) - GAP - EDGE;\n  const above = anchor.y - GAP - TOP_CLEARANCE;\n  const placeBelow =\n    side === \"bottom\" ||\n    (side === \"auto\" && (below >= contentHeight || below >= above));\n  const maxHeight = Math.max(120, placeBelow ? below : above);\n  const height = Math.min(contentHeight, maxHeight);\n  const scrollable = contentHeight > maxHeight;\n  const desired =\n    align === \"start\"\n      ? anchor.x\n      : align === \"end\"\n        ? anchor.x + anchor.width - width\n        : anchor.x + anchor.width / 2 - width / 2;\n  const left = Math.max(EDGE, Math.min(desired, window.width - width - EDGE));\n  const top = placeBelow\n    ? anchor.y + anchor.height + GAP\n    : anchor.y - GAP - height;\n  // Grow out of the point nearest the trigger.\n  const originX = Math.max(\n    0,\n    Math.min(width, anchor.x + anchor.width / 2 - left),\n  );\n\n  const indexAt = (y: number) => {\n    \"worklet\";\n    const list = slots.value;\n    for (let i = 0; i < list.length; i++) {\n      const slot = list[i];\n      if (slot && y >= slot.top && y < slot.top + slot.height)\n        return slot.disabled ? -1 : i;\n    }\n    return -1;\n  };\n\n  const tap = Gesture.Tap()\n    .maxDuration(600)\n    .onBegin((event) => {\n      highlight.value = indexAt(event.y);\n    })\n    .onEnd((event, success) => {\n      const index = indexAt(event.y);\n      if (success && index >= 0) scheduleOnRN(selectAt, index);\n    })\n    .onFinalize(() => {\n      highlight.value = -1;\n    });\n  // Slide to choose when everything fits; a still tap falls through to the\n  // tap. A scrolling menu leaves dragging to the scroll and chooses on tap.\n  const choose = scrollable\n    ? tap\n    : Gesture.Exclusive(\n        Gesture.Pan()\n          .minDistance(4)\n          .onBegin((event) => {\n            highlight.value = indexAt(event.y);\n          })\n          .onUpdate((event) => {\n            highlight.value = indexAt(event.y);\n          })\n          .onEnd((event) => {\n            const index = indexAt(event.y);\n            if (index >= 0) scheduleOnRN(selectAt, index);\n          })\n          .onFinalize(() => {\n            highlight.value = -1;\n          }),\n        tap,\n      );\n\n  const list = (\n    <GestureDetector gesture={choose}>\n      <View\n        collapsable={false}\n        onLayout={({ nativeEvent }) =>\n          setContentHeight(nativeEvent.layout.height)\n        }\n      >\n        <Highlight slots={slots} highlight={highlight} />\n        <PageSlide page={page} direction={direction} reduce={reduce}>\n          {children}\n        </PageSlide>\n      </View>\n    </GestureDetector>\n  );\n\n  return (\n    <Modal\n      transparent\n      visible\n      animationType=\"none\"\n      statusBarTranslucent\n      navigationBarTranslucent\n      onRequestClose={() => (stack.length > 0 ? back() : setOpen(false))}\n    >\n      <Animated.View\n        style={[StyleSheet.absoluteFill, scrimStyle]}\n        className=\"bg-foreground/5\"\n      >\n        <Pressable\n          accessibilityLabel=\"Close menu\"\n          style={StyleSheet.absoluteFill}\n          onPress={() => setOpen(false)}\n        />\n      </Animated.View>\n      <Animated.View\n        accessibilityViewIsModal\n        accessibilityRole=\"menu\"\n        layout={reduce ? undefined : PANEL_RESIZE}\n        style={[\n          styles.panel,\n          {\n            left,\n            top,\n            width,\n            transformOrigin: [originX, placeBelow ? \"0%\" : \"100%\", 0],\n          },\n          panelStyle,\n        ]}\n        className={`rounded-2xl border border-border bg-background p-1.5 ${className}`}\n      >\n        {scrollable ? (\n          <ScrollView\n            style={{ maxHeight: maxHeight - 12 }}\n            bounces={false}\n            showsVerticalScrollIndicator={false}\n          >\n            {list}\n          </ScrollView>\n        ) : (\n          list\n        )}\n      </Animated.View>\n    </Modal>\n  );\n}\n\nconst styles = StyleSheet.create({\n  flex: { flex: 1, minWidth: 0 },\n  item: { flexDirection: \"row\", alignItems: \"center\", gap: 12, minHeight: 44 },\n  disabled: { opacity: 0.4 },\n  icon: { width: 22, alignItems: \"center\" },\n  backRow: { gap: 4 },\n  // The glyph's stroke starts 4 points into its 16 point box.\n  backChevron: { marginLeft: -4 },\n  check: { width: 20, alignItems: \"center\" },\n  separator: { height: StyleSheet.hairlineWidth },\n  highlight: { position: \"absolute\", left: 0, right: 0, borderRadius: 12 },\n  panel: {\n    position: \"absolute\",\n    shadowColor: \"#000\",\n    shadowOpacity: 0.12,\n    shadowRadius: 28,\n    shadowOffset: { width: 0, height: 10 },\n    elevation: 10,\n  },\n});\n","type":"shared"},{"path":"src/hooks/use-presence.ts","content":"import { useEffect, useRef, useState } from \"react\";\n\nexport type PresenceItem<T> = {\n  key: string;\n  value: T;\n  /** False for the item present on first render, so it does not animate in. */\n  appear: boolean;\n  exiting: boolean;\n};\n\n/**\n * Keeps replaced items mounted while they animate out, like AnimatePresence.\n * A key that returns while exiting is revived in place.\n */\nexport function usePresence<T>(\n  key: string | null,\n  value: T,\n  exitDuration: (value: T) => number,\n) {\n  const [items, setItems] = useState<PresenceItem<T>[]>(() =>\n    key === null ? [] : [{ key, value, appear: false, exiting: false }],\n  );\n  const [currentKey, setCurrentKey] = useState(key);\n  const exitDurationRef = useRef(exitDuration);\n  exitDurationRef.current = exitDuration;\n\n  // Adjust during render so the new item and the exits start in the same frame.\n  if (key !== currentKey) {\n    setCurrentKey(key);\n    setItems((previous) => {\n      const next = previous.map((item) =>\n        item.key === key\n          ? { ...item, value, exiting: false }\n          : { ...item, exiting: true },\n      );\n      if (key !== null && !previous.some((item) => item.key === key)) {\n        next.push({ key, value, appear: true, exiting: false });\n      }\n      return next;\n    });\n  }\n\n  const exitingKeys = items\n    .filter((item) => item.exiting)\n    .map((item) => item.key)\n    .join(\"\\n\");\n\n  useEffect(() => {\n    if (!exitingKeys) return;\n    const timers = items\n      .filter((item) => item.exiting)\n      .map((item) =>\n        setTimeout(() => {\n          setItems((previous) =>\n            previous.filter(\n              (candidate) => !(candidate.key === item.key && candidate.exiting),\n            ),\n          );\n        }, exitDurationRef.current(item.value)),\n      );\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n    // Restart removal timers only when the set of exiting items changes.\n  }, [exitingKeys]);\n\n  return items;\n}\n","type":"shared"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/primitives/agent-icons.tsx","content":"import type { ReactNode } from \"react\";\nimport type { ColorValue } from \"react-native\";\nimport Svg, { Circle, Path, Rect } from \"react-native-svg\";\n\ntype GlyphProps = { size?: number; color?: ColorValue; strokeWidth?: number };\n\nfunction Glyph({\n  size = 16,\n  color = \"currentColor\",\n  strokeWidth = 2,\n  children,\n}: GlyphProps & { children: ReactNode }) {\n  return (\n    <Svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke={color}\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {children}\n    </Svg>\n  );\n}\n\n// Simple 24-unit stroke glyphs shared by the agent components.\n\nexport function ChevronDownGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6 9l6 6 6-6\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowUpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 19V5M5.5 11.5L12 5l6.5 6.5\" />\n    </Glyph>\n  );\n}\n\nexport function PlusGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 5v14M5 12h14\" />\n    </Glyph>\n  );\n}\n\nexport function StopGlyph({ size = 12, color }: GlyphProps) {\n  return (\n    <Svg width={size} height={size} viewBox=\"0 0 12 12\">\n      <Rect x={1} y={1} width={10} height={10} rx={2.5} fill={color} />\n    </Svg>\n  );\n}\n\nexport function CheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M5 12.5l4.5 4.5L19 7\" />\n    </Glyph>\n  );\n}\n\nexport function CopyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={8.5} y={8.5} width={12} height={12} rx={2.5} />\n      <Path d=\"M15.5 5.5v-.5A2 2 0 0 0 13.5 3h-8.5A2 2 0 0 0 3 5v8.5a2 2 0 0 0 2 2h.5\" />\n    </Glyph>\n  );\n}\n\nexport function RetryGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5\" />\n      <Path d=\"M3.5 3.5v5h5\" />\n    </Glyph>\n  );\n}\n\nexport function ThumbGlyph({\n  down = false,\n  ...props\n}: GlyphProps & { down?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M7.5 10.5v10M7.5 10.5L11 3.5a2.5 2.5 0 0 1 2.5 2.5v3.5h5a2 2 0 0 1 2 2.3l-1.2 7A2 2 0 0 1 17.3 20.5H4.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z\"\n        transform={down ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function SearchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={11} cy={11} r={6.5} />\n      <Path d=\"M16 16l4 4\" />\n    </Glyph>\n  );\n}\n\nexport function GlobeGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M3.5 12h17M12 3.5c2.4 2.5 3.5 5.3 3.5 8.5s-1.1 6-3.5 8.5c-2.4-2.5-3.5-5.3-3.5-8.5s1.1-6 3.5-8.5z\" />\n    </Glyph>\n  );\n}\n\nexport function FileGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 3.5H7.5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8z\" />\n      <Path d=\"M14 3.5V8h4.5M9 13h6M9 16.5h4\" />\n    </Glyph>\n  );\n}\n\nexport function PencilGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 20h4L19 9a2.1 2.1 0 0 0-4-4L4 16z\" />\n      <Path d=\"M13.5 6.5l4 4M14 20h6\" />\n    </Glyph>\n  );\n}\n\nexport function TerminalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Path d=\"M7.5 10l2.5 2-2.5 2M12.5 15h4\" />\n    </Glyph>\n  );\n}\n\nexport function WrenchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14.5 6.5a4 4 0 0 0 5 5l-8.8 8.8a2.1 2.1 0 0 1-3-3l8.8-8.8a4 4 0 0 1-2-2z\" />\n    </Glyph>\n  );\n}\n\nexport function SparkleGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 4l1.8 4.6L18.5 10.5l-4.7 1.9L12 17l-1.8-4.6-4.7-1.9 4.7-1.9z\" />\n      <Path d=\"M18.5 16.5v3M17 18h3\" />\n    </Glyph>\n  );\n}\n\nexport function MessageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20 14.5a2 2 0 0 1-2 2H8.5L4 20V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z\" />\n    </Glyph>\n  );\n}\n\nexport function ImageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Circle cx={9} cy={10} r={1.5} />\n      <Path d=\"M20.5 15.5l-4.5-4.5-9 8.5\" />\n    </Glyph>\n  );\n}\n\nexport function ShieldGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 3.5l7 2.5v5.5c0 4.4-3 7.7-7 9-4-1.3-7-4.6-7-9V6z\" />\n      <Path d=\"M9 12l2 2 4-4\" />\n    </Glyph>\n  );\n}\n\nexport function AlertGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 8v4.5M12 16h.01\" />\n    </Glyph>\n  );\n}\n\nexport function CrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\" />\n    </Glyph>\n  );\n}\n\nexport function HelpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.6 9.5a2.5 2.5 0 0 1 4.8 1c0 1.7-2.4 2.2-2.4 3.5M12 16.8h.01\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowGlyph({\n  back = false,\n  ...props\n}: GlyphProps & { back?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M5 12h14M13 6l6 6-6 6\"\n        transform={back ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function BracesGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M8.5 4H8a2 2 0 0 0-2 2v3.5A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5V18a2 2 0 0 0 2 2h.5M15.5 4h.5a2 2 0 0 1 2 2v3.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5V18a2 2 0 0 1-2 2h-.5\" />\n    </Glyph>\n  );\n}\n\nexport function BanGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M6 6l12 12\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M8.5 12.2l2.4 2.4 4.6-4.8\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.5 9.5l5 5M14.5 9.5l-5 5\" />\n    </Glyph>\n  );\n}\n\nexport function ListGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5l1.5 1.5L8 5.5M4 13l1.5 1.5L8 12M11.5 7h8.5M11.5 13.5h8.5M11.5 19h8.5M4.5 19h2\" />\n    </Glyph>\n  );\n}\n\n/** An open ring, spun by SpinGlyph. */\nexport function RingGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20.5 12a8.5 8.5 0 1 1-6-8.1\" />\n    </Glyph>\n  );\n}\n\nexport function ReplyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M9.5 7L4.5 12l5 5M4.5 12h10a5 5 0 0 1 5 5v1\" />\n    </Glyph>\n  );\n}\n\nexport function WrapGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5h16M4 12h12.5a3.25 3.25 0 0 1 0 6.5H12M4 18.5h4.5M14 16.5l-2 2 2 2\" />\n    </Glyph>\n  );\n}\n\nexport function BookGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 6.5c-1.6-1.3-4-2-7.5-2v13c3.5 0 5.9.7 7.5 2 1.6-1.3 4-2 7.5-2v-13c-3.5 0-5.9.7-7.5 2zM12 6.5v13\" />\n    </Glyph>\n  );\n}\n\nexport function ExternalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 4.5h5.5V10M19.5 4.5L11 13M17 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4\" />\n    </Glyph>\n  );\n}\n\nexport function DoubleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M2.5 12.5l4.5 4.5 9-9.5M12 16.5l.5.5 9-9.5\" />\n    </Glyph>\n  );\n}\n\nexport function ClockGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 7.5V12l3 2\" />\n    </Glyph>\n  );\n}\n\nexport function MicGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={9} y={3.5} width={6} height={11} rx={3} />\n      <Path d=\"M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3\" />\n    </Glyph>\n  );\n}\n","type":"shared"},{"path":"src/primitives/render-icon.tsx","content":"import {\n  type ComponentType,\n  createElement,\n  isValidElement,\n  type ReactNode,\n} from \"react\";\nimport type { ColorValue } from \"react-native\";\n\nexport type IconProps = { size: number; color: ColorValue | undefined };\n/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */\nexport type IconSource = ComponentType<IconProps> | ReactNode;\n\nexport function renderIcon(icon: IconSource, props: IconProps) {\n  if (typeof icon === \"function\") return createElement(icon, props);\n  // forwardRef and memo components (lucide-react-native, for example) are objects.\n  if (\n    icon != null &&\n    typeof icon === \"object\" &&\n    !isValidElement(icon) &&\n    \"$$typeof\" in icon\n  ) {\n    return createElement(icon as unknown as ComponentType<IconProps>, props);\n  }\n  return icon;\n}\n","type":"shared"},{"path":"src/primitives/rise-in.tsx","content":"import { type ReactNode, useEffect } from \"react\";\nimport type { ViewProps } from \"react-native\";\nimport Animated, {\n  LinearTransition,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"../styles/motion\";\n\nconst FADE = { duration: 180, easing: EASE_OUT };\nconst RISE = 6;\nconst EXIT_DURATION = 140;\n\n/** Leaving rows reverse their entrance: fade and lift 3 points away. */\nfunction riseOut() {\n  \"worklet\";\n  return {\n    initialValues: { opacity: 1, transform: [{ translateY: 0 }] },\n    animations: {\n      opacity: withTiming(0, { duration: EXIT_DURATION, easing: EASE_OUT }),\n      transform: [\n        {\n          translateY: withTiming(-3, {\n            duration: EXIT_DURATION,\n            easing: EASE_OUT,\n          }),\n        },\n      ],\n    },\n  };\n}\n\nconst GLIDE = LinearTransition.springify()\n  .stiffness(SPRING_LAYOUT.stiffness)\n  .damping(SPRING_LAYOUT.damping)\n  .mass(SPRING_LAYOUT.mass);\n\n/**\n * A row that fades and rises 6 points into place when it mounts, and lifts\n * away as it leaves, while its neighbours glide to make room. Shared by the agent activity lists.\n */\nexport function RiseIn({\n  reduce,\n  appear = true,\n  children,\n  style,\n  ...props\n}: ViewProps & { reduce: boolean; appear?: boolean; children: ReactNode }) {\n  const animate = appear && !reduce;\n  const opacity = useSharedValue(animate ? 0 : 1);\n  const lift = useSharedValue(animate ? RISE : 0);\n\n  // Plays once, on mount.\n  useEffect(() => {\n    if (!animate) return;\n    opacity.value = withTiming(1, FADE);\n    lift.value = withSpring(0, SPRING_LAYOUT);\n  }, []);\n\n  const rise = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ translateY: lift.value }],\n  }));\n\n  return (\n    <Animated.View\n      layout={reduce ? undefined : GLIDE}\n      exiting={reduce ? undefined : riseOut}\n      style={[rise, style]}\n      {...props}\n    >\n      {children}\n    </Animated.View>\n  );\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n\n  /* Tailwind's default monospace stack isn't a font React Native can load. */\n  :root {\n    @variant ios {\n      --font-mono: Menlo;\n    }\n  }\n\n  :root {\n    @variant android {\n      --font-mono: monospace;\n    }\n  }\n}\n","type":"theme"}]}