{"slug":"message","name":"Message","description":"Rows that rise out of their corner, with avatars and typing dots.","group":"AI agents","detail_url":"https://beui.dev/native/r/message","page_url":"https://beui.dev/react-native/message","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/message?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/message","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/message/index.ts","content":"export type {\n  MessageAvatarProps,\n  MessageDelivery,\n  MessageFrom,\n  MessageGroupProps,\n  MessageProps,\n  MessageStatusProps,\n  MessageTypingProps,\n} from \"./message\";\nexport {\n  Message,\n  MessageAvatar,\n  MessageContent,\n  MessageFooter,\n  MessageGroup,\n  MessageHeader,\n  MessageMarker,\n  MessageStatus,\n  MessageTyping,\n} from \"./message\";\n","type":"component"},{"path":"src/components/message/message.tsx","content":"import { type ReactNode, type Ref, useContext, useEffect } from \"react\";\nimport { StyleSheet, Text, View, type ViewProps } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withRepeat,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  AlertGlyph,\n  CheckGlyph,\n  ClockGlyph,\n  DoubleCheckGlyph,\n} from \"../../primitives/agent-icons\";\nimport { RollText } from \"../../primitives/roll-text\";\nimport { EASE_OUT, SPRING_SWAP } from \"../../styles/motion\";\nimport { MessageSideContext } from \"../message-bubble/message-bubble\";\n\nexport type MessageFrom = \"user\" | \"assistant\";\n\n// The row rises out of its trailing corner as it's sent.\nconst MESSAGE_POP_UP = {\n  stiffness: 480,\n  damping: 32,\n  mass: 0.62,\n  reduceMotion: ReduceMotion.System,\n};\nconst RISE = 8;\n\nexport type MessageProps = Omit<ViewProps, \"children\"> & {\n  from: MessageFrom;\n  /** Pops the row up from its trailing corner once, on mount. Defaults to false. */\n  animateIn?: boolean;\n  /** Appears without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  children: ReactNode;\n  ref?: Ref<View>;\n};\n\n/**\n * One message row: an optional avatar beside MessageContent. User rows sit on\n * the trailing side, and bubbles inside follow the row's side.\n */\nexport function Message({\n  from,\n  animateIn = false,\n  disableAnimation = false,\n  className = \"\",\n  style,\n  children,\n  ref,\n  ...props\n}: MessageProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const pop = animateIn && !reducedMotion && !disableAnimation;\n  const progress = useSharedValue(pop ? 0 : 1);\n  const user = from === \"user\";\n\n  // Plays once, on mount.\n  useEffect(() => {\n    if (pop) progress.value = withSpring(1, MESSAGE_POP_UP);\n  }, []);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.min(1, p),\n      transform: [{ translateY: (1 - p) * RISE }, { scale: 0.95 + p * 0.05 }],\n    };\n  });\n\n  return (\n    <MessageSideContext.Provider value={user ? \"end\" : \"start\"}>\n      <Animated.View\n        ref={ref}\n        style={[\n          styles.row,\n          user && styles.reverse,\n          { transformOrigin: [user ? \"100%\" : \"0%\", \"100%\", 0] },\n          animatedStyle,\n          style,\n        ]}\n        className={className}\n        {...props}\n      >\n        {children}\n      </Animated.View>\n    </MessageSideContext.Provider>\n  );\n}\n\nexport type MessageGroupProps = ViewProps & {\n  /** Space between rows: 6 (compact) or 16 points. Defaults to \"compact\". */\n  spacing?: \"compact\" | \"default\";\n  className?: string;\n};\n\n/** Stacks message rows. */\nexport function MessageGroup({\n  spacing = \"compact\",\n  className = \"\",\n  ...props\n}: MessageGroupProps) {\n  return (\n    <View\n      className={`${spacing === \"compact\" ? \"gap-1.5\" : \"gap-4\"} ${className}`}\n      {...props}\n    />\n  );\n}\n\nexport type MessageAvatarProps = ViewProps & {\n  /** Keeps the slot empty so grouped rows stay aligned. */\n  placeholder?: boolean;\n  className?: string;\n  /** Initials, an image or an icon. Strings are styled for you. */\n  children?: ReactNode;\n};\n\n/** A 28 point round avatar. */\nexport function MessageAvatar({\n  placeholder = false,\n  className = \"\",\n  children,\n  ...props\n}: MessageAvatarProps) {\n  return (\n    <View\n      importantForAccessibility={placeholder ? \"no-hide-descendants\" : \"auto\"}\n      accessibilityElementsHidden={placeholder}\n      style={[styles.avatar, placeholder && styles.hidden]}\n      className={`items-center justify-center overflow-hidden bg-surface ${className}`}\n      {...props}\n    >\n      {placeholder ? null : typeof children === \"string\" ? (\n        <Text className=\"text-xs font-medium text-muted\">{children}</Text>\n      ) : (\n        children\n      )}\n    </View>\n  );\n}\n\n/** The column beside the avatar, holding header, bubbles and footer. */\nexport function MessageContent({\n  className = \"\",\n  ...props\n}: ViewProps & { className?: string }) {\n  const side = useContext(MessageSideContext);\n  return (\n    <View\n      style={[\n        styles.content,\n        { alignItems: side === \"end\" ? \"flex-end\" : \"flex-start\" },\n      ]}\n      className={`gap-1.5 ${className}`}\n      {...props}\n    />\n  );\n}\n\nfunction metaRow(minHeight: boolean) {\n  return function MetaRow({\n    className = \"\",\n    children,\n    ...props\n  }: ViewProps & { className?: string }) {\n    return (\n      <View\n        style={minHeight && styles.footer}\n        className={`flex-row items-center gap-1.5 px-1 ${className}`}\n        {...props}\n      >\n        {typeof children === \"string\" ? (\n          <Text className=\"text-[11px] text-muted\">{children}</Text>\n        ) : (\n          children\n        )}\n      </View>\n    );\n  };\n}\n\n/** A small line above the bubbles: a name, a time. */\nexport const MessageHeader = metaRow(false);\n/** A small line below the bubbles: delivery state, actions. */\nexport const MessageFooter = metaRow(true);\n\nexport type MessageDelivery =\n  | \"sending\"\n  | \"sent\"\n  | \"delivered\"\n  | \"read\"\n  | \"failed\";\n\nconst DELIVERY_LABEL: Record<MessageDelivery, string> = {\n  sending: \"Sending\",\n  sent: \"Sent\",\n  delivered: \"Delivered\",\n  read: \"Read\",\n  failed: \"Not delivered\",\n};\n\n/** The delivery icon, swapping with a quick scale as the state moves on. */\nfunction DeliveryIcon({\n  delivery,\n  appear,\n  exiting,\n  reduce,\n}: {\n  delivery: MessageDelivery;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n}) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const { color: primary } = useResolveClassNames(\"text-primary\");\n  const { color: destructive } = useResolveClassNames(\"text-destructive\");\n  const shown = useSharedValue(appear && !reduce ? 0 : 1);\n  useEffect(() => {\n    shown.value = reduce\n      ? exiting\n        ? 0\n        : 1\n      : withSpring(exiting ? 0 : 1, SPRING_SWAP);\n  }, [exiting, reduce, shown]);\n  const style = useAnimatedStyle(() => ({\n    opacity: shown.value,\n    transform: [{ scale: 0.6 + shown.value * 0.4 }],\n  }));\n  const props = { size: 13, strokeWidth: 2 };\n  return (\n    <Animated.View style={[styles.deliveryIcon, style]}>\n      {delivery === \"sending\" ? (\n        <ClockGlyph {...props} color={muted} />\n      ) : delivery === \"sent\" ? (\n        <CheckGlyph {...props} color={muted} />\n      ) : delivery === \"failed\" ? (\n        <AlertGlyph {...props} color={destructive} />\n      ) : (\n        <DoubleCheckGlyph\n          {...props}\n          color={delivery === \"read\" ? primary : muted}\n        />\n      )}\n    </Animated.View>\n  );\n}\n\nexport type MessageStatusProps = ViewProps & {\n  /** When the message was sent, already formatted, such as \"9:41\". */\n  time?: string;\n  /** Where the message is. The icon and label roll as it changes. */\n  delivery?: MessageDelivery;\n  /** Shows the delivery word beside its icon. Defaults to true. */\n  showLabel?: boolean;\n  disableAnimation?: boolean;\n};\n\n/**\n * The time and delivery state under a message, such as \"9:41 · Delivered\"\n * with two checks. As the state moves on, the label rolls and the icon swaps.\n */\nexport function MessageStatus({\n  time,\n  delivery,\n  showLabel = true,\n  disableAnimation = false,\n  ...props\n}: MessageStatusProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const icons = usePresence(\n    delivery ?? null,\n    delivery as MessageDelivery,\n    () => (reduce ? 0 : 220),\n  );\n  const spoken = [time, delivery ? DELIVERY_LABEL[delivery] : undefined]\n    .filter(Boolean)\n    .join(\", \");\n  return (\n    <MessageFooter\n      accessible\n      accessibilityLabel={spoken}\n      accessibilityLiveRegion=\"polite\"\n      {...props}\n    >\n      {time ? (\n        <Text className=\"text-[11px] text-muted\" style={styles.tabular}>\n          {time}\n        </Text>\n      ) : null}\n      {time && delivery && showLabel ? (\n        <Text className=\"text-[11px] text-muted\">·</Text>\n      ) : null}\n      {delivery && showLabel ? (\n        <RollText\n          text={DELIVERY_LABEL[delivery]}\n          className={`text-[11px] ${delivery === \"failed\" ? \"text-destructive\" : \"text-muted\"}`}\n          reduce={reduce}\n        />\n      ) : null}\n      {delivery ? (\n        <View style={styles.deliverySlot}>\n          {icons.map((item) => (\n            <DeliveryIcon\n              key={item.key}\n              delivery={item.value}\n              appear={item.appear}\n              exiting={item.exiting}\n              reduce={reduce}\n            />\n          ))}\n        </View>\n      ) : null}\n    </MessageFooter>\n  );\n}\n\n/** A centered pill between messages, for dates and system notes. */\nexport function MessageMarker({\n  className = \"\",\n  children,\n  ...props\n}: ViewProps & { className?: string }) {\n  return (\n    <View\n      className={`max-w-[88%] self-center rounded-full bg-surface px-2.5 py-1 ${className}`}\n      {...props}\n    >\n      {typeof children === \"string\" ? (\n        <Text className=\"text-xs text-muted\">{children}</Text>\n      ) : (\n        children\n      )}\n    </View>\n  );\n}\n\nconst DOT_CYCLE = 1050;\nconst DOT_STAGGER = 140;\n\nfunction TypingDot({\n  index,\n  color,\n  reduce,\n}: {\n  index: number;\n  color: string;\n  reduce: boolean;\n}) {\n  const pulse = useSharedValue(0);\n\n  useEffect(() => {\n    if (reduce) {\n      cancelAnimation(pulse);\n      pulse.value = 0;\n      return;\n    }\n    pulse.value = withDelay(\n      index * DOT_STAGGER,\n      withRepeat(\n        withTiming(1, { duration: DOT_CYCLE / 2, easing: EASE_OUT }),\n        -1,\n        true,\n      ),\n    );\n    return () => cancelAnimation(pulse);\n  }, [index, pulse, reduce]);\n\n  const style = useAnimatedStyle(() =>\n    reduce\n      ? { opacity: 0.45 }\n      : {\n          opacity: 0.28 + pulse.value * 0.57,\n          transform: [{ translateY: -2 * pulse.value }],\n        },\n  );\n\n  return (\n    <Animated.View style={[styles.dot, { backgroundColor: color }, style]} />\n  );\n}\n\nexport type MessageTypingProps = ViewProps & {\n  /** Spoken in place of the dots. Defaults to \"Responding\". */\n  label?: string;\n  /** Color utility for the dots. Defaults to `text-muted`. */\n  className?: string;\n  disableAnimation?: boolean;\n};\n\n/** Three dots that rise and fade in turn while a reply is on its way. */\nexport function MessageTyping({\n  label = \"Responding\",\n  className = \"text-muted\",\n  disableAnimation = false,\n  ...props\n}: MessageTypingProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const { color } = useResolveClassNames(className);\n  const tint = String(color ?? \"gray\");\n  return (\n    <View\n      accessible\n      accessibilityLabel={label}\n      accessibilityRole=\"progressbar\"\n      style={styles.typing}\n      {...props}\n    >\n      {[0, 1, 2].map((index) => (\n        <TypingDot\n          key={index}\n          index={index}\n          color={tint}\n          reduce={reducedMotion || disableAnimation}\n        />\n      ))}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: {\n    flexDirection: \"row\",\n    alignItems: \"flex-start\",\n    gap: 8,\n    width: \"100%\",\n  },\n  reverse: { flexDirection: \"row-reverse\" },\n  avatar: { width: 28, height: 28, borderRadius: 14 },\n  hidden: { opacity: 0 },\n  content: { flex: 1, minWidth: 0 },\n  footer: { minHeight: 20 },\n  typing: { flexDirection: \"row\", alignItems: \"center\", gap: 4, height: 20 },\n  dot: { width: 4, height: 4, borderRadius: 2 },\n  deliverySlot: { width: 14, height: 14 },\n  deliveryIcon: { position: \"absolute\", left: 0, top: 0 },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n});\n","type":"component"},{"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/fade-edge.tsx","content":"import {\n  type ColorValue,\n  StyleSheet,\n  View,\n  type ViewStyle,\n} from \"react-native\";\n\nconst BANDS = 8;\n\nexport type FadeEdgeProps = {\n  /** The surface color the content fades into. */\n  color: ColorValue | undefined;\n  /** Which edge is solid. Defaults to \"bottom\". */\n  edge?: \"top\" | \"bottom\";\n  /** Height of the fade in points. */\n  height: number;\n  style?: ViewStyle;\n};\n\n/**\n * A soft edge over clipped content, built from stacked bands so shared\n * components need no gradient library. Ignores touches.\n */\nexport function FadeEdge({\n  color,\n  edge = \"bottom\",\n  height,\n  style,\n}: FadeEdgeProps) {\n  return (\n    <View\n      pointerEvents=\"none\"\n      style={[\n        styles.edge,\n        edge === \"bottom\" ? styles.bottom : styles.top,\n        { height },\n        style,\n      ]}\n    >\n      {Array.from({ length: BANDS }, (_, index) => {\n        const t = (index + 1) / BANDS;\n        return (\n          <View\n            key={index}\n            style={{\n              flex: 1,\n              backgroundColor: color,\n              // Ease the ramp so the fade has no visible first step.\n              opacity: edge === \"bottom\" ? t * t : (1 - t + 1 / BANDS) ** 2,\n            }}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  edge: { position: \"absolute\", left: 0, right: 0 },\n  bottom: { bottom: 0 },\n  top: { top: 0 },\n});\n","type":"shared"},{"path":"src/primitives/roll-text.tsx","content":"import { useEffect, useState } from \"react\";\nimport { StyleSheet, Text, View } from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { usePresence } from \"../hooks/use-presence\";\nimport { EASE_OUT, SPRING_SWAP } from \"../styles/motion\";\n\nconst EXIT = { duration: 140, easing: EASE_OUT };\nconst TRAVEL = 0.9;\n\n/** Progress runs 0 (below) → 1 (resting) → 2 (rolled away above). */\nfunction Layer({\n  text,\n  appear,\n  exiting,\n  reduce,\n  height,\n  className,\n  lines,\n}: {\n  text: string;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  height: number;\n  className: string;\n  lines?: number;\n}) {\n  const progress = useSharedValue(appear && !reduce ? 0 : 1);\n\n  useEffect(() => {\n    if (reduce) {\n      progress.value = exiting ? 2 : 1;\n      return;\n    }\n    progress.value = exiting ? withTiming(2, EXIT) : withSpring(1, SPRING_SWAP);\n  }, [exiting, progress, reduce]);\n\n  const style = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.max(0, Math.min(1, p <= 1 ? p : 2 - p)),\n      transform: [{ translateY: (1 - p) * height * TRAVEL }],\n    };\n  }, [height]);\n\n  return (\n    <Animated.View style={[styles.layer, style]}>\n      <Text numberOfLines={lines} className={className}>\n        {text}\n      </Text>\n    </Animated.View>\n  );\n}\n\nexport type RollTextProps = {\n  /** Changing the value rolls the new text in; defaults to the text itself. */\n  value?: string;\n  text: string;\n  className: string;\n  reduce: boolean;\n  /** Lines before truncating. Defaults to 1; pass 0 to wrap freely. */\n  numberOfLines?: number;\n};\n\n/**\n * Text that rolls up when it changes: the new line rises in from below as the\n * old one rolls up and away. Clipped to its own lines, so only the text moves.\n */\nexport function RollText({\n  value,\n  text,\n  className,\n  reduce,\n  numberOfLines = 1,\n}: RollTextProps) {\n  const lines = numberOfLines === 0 ? undefined : numberOfLines;\n  const [height, setHeight] = useState(16);\n  const items = usePresence(value ?? text, text, () =>\n    reduce ? 0 : EXIT.duration + 40,\n  );\n\n  return (\n    <View style={styles.slot}>\n      {/* Sizes the slot to the current text. */}\n      <Text\n        numberOfLines={lines}\n        className={className}\n        style={styles.sizer}\n        onLayout={({ nativeEvent }) => setHeight(nativeEvent.layout.height)}\n      >\n        {text}\n      </Text>\n      {items.map((item) => (\n        <Layer\n          key={item.key}\n          text={item.value}\n          appear={item.appear}\n          exiting={item.exiting}\n          reduce={reduce}\n          height={height}\n          className={className}\n          lines={lines}\n        />\n      ))}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  slot: { overflow: \"hidden\", flexShrink: 1, minWidth: 0 },\n  sizer: { opacity: 0 },\n  layer: { position: \"absolute\", left: 0, right: 0, top: 0 },\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"}]}