{"slug":"toast","name":"Toast","description":"Pill toasts that stack, spread out and swipe away.","group":"Motion","detail_url":"https://beui.dev/native/r/toast","page_url":"https://beui.dev/react-native/toast","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/toast?runtime-version=exposdk%3A58.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/toast","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-screens","react-native-svg","react-native-worklets","uniwind"],"install":["npx expo install react-native-gesture-handler react-native-reanimated react-native-screens 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/toast/index.ts","content":"export {\n  type ToastAction,\n  type ToastItem,\n  type ToastOptions,\n  type ToastStatus,\n  toast,\n} from \"./toast-store\";\nexport { Toaster, type ToasterProps } from \"./toaster\";\n","type":"component"},{"path":"src/components/toast/toast-store.ts","content":"export type ToastStatus = \"neutral\" | \"info\" | \"loading\" | \"success\" | \"error\";\n\nexport type ToastAction = {\n  label: string;\n  onPress: () => void;\n};\n\nexport type ToastOptions = {\n  id?: string;\n  description?: string;\n  status?: ToastStatus;\n  action?: ToastAction;\n  /** Milliseconds before it leaves; 0 keeps it up. Loading toasts stay up. */\n  duration?: number;\n  /** Whether a swipe can dismiss it. Defaults to true. */\n  dismissible?: boolean;\n};\n\nexport type ToastItem = {\n  id: string;\n  title: string;\n  description?: string;\n  status: ToastStatus;\n  action?: ToastAction;\n  duration: number;\n  dismissible: boolean;\n  /** Set when it starts leaving; the Toaster removes it after its exit. */\n  dismissed: boolean;\n};\n\nconst DEFAULT_DURATION = 4000;\n\ntype Timer = {\n  handle: ReturnType<typeof setTimeout> | null;\n  remaining: number;\n  startedAt: number;\n};\n\nlet items: ToastItem[] = [];\nlet paused = false;\nlet seed = 0;\nconst timers = new Map<string, Timer>();\nconst listeners = new Set<() => void>();\n\nfunction emit() {\n  for (const listener of listeners) listener();\n}\n\nfunction lifetime(item: ToastItem) {\n  return item.status === \"loading\" ? 0 : item.duration;\n}\n\nfunction startTimer(id: string, ms: number) {\n  stopTimer(id);\n  if (ms <= 0) return;\n  const timer: Timer = { handle: null, remaining: ms, startedAt: Date.now() };\n  if (!paused) timer.handle = setTimeout(() => dismiss(id), ms);\n  timers.set(id, timer);\n}\n\nfunction stopTimer(id: string) {\n  const timer = timers.get(id);\n  if (timer?.handle) clearTimeout(timer.handle);\n  timers.delete(id);\n}\n\nfunction show(title: string, options: ToastOptions = {}) {\n  const id = options.id ?? `toast-${Date.now()}-${seed++}`;\n  if (items.some((item) => item.id === id)) {\n    update(id, { ...options, title });\n    return id;\n  }\n  const item: ToastItem = {\n    id,\n    title,\n    description: options.description,\n    status: options.status ?? \"neutral\",\n    action: options.action,\n    duration: options.duration ?? DEFAULT_DURATION,\n    dismissible: options.dismissible ?? true,\n    dismissed: false,\n  };\n  items = [...items, item];\n  startTimer(id, lifetime(item));\n  emit();\n  return id;\n}\n\nfunction update(id: string, patch: ToastOptions & { title?: string }) {\n  let changed: ToastItem | undefined;\n  items = items.map((item) => {\n    if (item.id !== id) return item;\n    changed = {\n      ...item,\n      ...patch,\n      id,\n      duration: patch.duration ?? item.duration,\n      status: patch.status ?? item.status,\n    };\n    return changed;\n  });\n  // A new status or duration restarts the clock.\n  if (changed && (patch.status || patch.duration !== undefined))\n    startTimer(id, lifetime(changed));\n  emit();\n}\n\nfunction dismiss(id?: string) {\n  const ids = id ? [id] : items.map((item) => item.id);\n  for (const each of ids) stopTimer(each);\n  items = items.map((item) =>\n    ids.includes(item.id) ? { ...item, dismissed: true } : item,\n  );\n  emit();\n}\n\nfunction promise<T>(\n  task: Promise<T>,\n  messages: {\n    loading: string;\n    success: string | ((value: T) => string);\n    error: string | ((error: unknown) => string);\n  },\n  options: Omit<ToastOptions, \"status\"> = {},\n) {\n  const id = show(messages.loading, { ...options, status: \"loading\" });\n  task.then(\n    (value) =>\n      update(id, {\n        status: \"success\",\n        title:\n          typeof messages.success === \"function\"\n            ? messages.success(value)\n            : messages.success,\n      }),\n    (error: unknown) =>\n      update(id, {\n        status: \"error\",\n        title:\n          typeof messages.error === \"function\"\n            ? messages.error(error)\n            : messages.error,\n      }),\n  );\n  return task;\n}\n\ntype Show = (title: string, options?: Omit<ToastOptions, \"status\">) => string;\nconst withStatus =\n  (status: ToastStatus): Show =>\n  (title, options) =>\n    show(title, { ...options, status });\n\n/**\n * Shows a toast from anywhere. Mount one `<Toaster />` near the app root to\n * display them.\n */\nexport const toast = Object.assign(\n  (title: string, options?: ToastOptions) => show(title, options),\n  {\n    success: withStatus(\"success\"),\n    error: withStatus(\"error\"),\n    info: withStatus(\"info\"),\n    loading: withStatus(\"loading\"),\n    update,\n    dismiss,\n    promise,\n  },\n);\n\n/** Internal: the Toaster's view of the store. */\nexport const toastStore = {\n  subscribe(listener: () => void) {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  },\n  getSnapshot: () => items,\n  /** Drops a toast once its exit has played. */\n  remove(id: string) {\n    stopTimer(id);\n    items = items.filter((item) => item.id !== id);\n    emit();\n  },\n  /** Holds every timer while a finger is down or the stack is open. */\n  pause() {\n    if (paused) return;\n    paused = true;\n    const now = Date.now();\n    for (const timer of timers.values()) {\n      if (timer.handle) clearTimeout(timer.handle);\n      timer.handle = null;\n      timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt));\n    }\n  },\n  resume() {\n    if (!paused) return;\n    paused = false;\n    for (const [id, timer] of timers) {\n      timer.startedAt = Date.now();\n      timer.handle = setTimeout(() => dismiss(id), timer.remaining);\n    }\n  },\n};\n","type":"component"},{"path":"src/components/toast/toaster.tsx","content":"import {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport {\n  AccessibilityInfo,\n  Platform,\n  Pressable,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n} from \"react-native\";\nimport {\n  Gesture,\n  GestureDetector,\n  GestureHandlerRootView,\n} from \"react-native-gesture-handler\";\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { FullWindowOverlay } from \"react-native-screens\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  CheckGlyph,\n  ExclaimGlyph,\n  InfoGlyph,\n} from \"../../primitives/agent-icons\";\nimport { RollText } from \"../../primitives/roll-text\";\nimport { SpinGlyph } from \"../../primitives/spin-glyph\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport {\n  type ToastItem,\n  type ToastStatus,\n  toast,\n  toastStore,\n} from \"./toast-store\";\n\n// Arriving runs linearly; each phase eases on its own slice of it, so\n// later phases are not crushed into the first frames of one ease-out.\nconst ENTER = { duration: 560, easing: Easing.linear } as const;\nconst EXIT = { duration: 420, easing: Easing.linear } as const;\n\n/** Ease-out over one phase's slice of a linear progress, clamped to 0..1. */\nfunction phase(progress: number, from: number, to: number) {\n  \"worklet\";\n  const t = Math.max(0, Math.min(1, (progress - from) / (to - from)));\n  return 1 - (1 - t) ** 3;\n}\n/** The smallest the shell starts from: a circle around the status. */\nconst MORPH_START = 44;\n/** The pill's corner radius, and the width of each rounded cap. */\nconst CAP = 22;\n/** A soft lift with the border, so the pill reads on any screen. */\nconst SHADOW = \"0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06)\";\n/** The drop covers the first half, from this far past the edge. */\nconst DROP_FROM = 24;\nconst REFLOW = { duration: 220, easing: EASE_OUT } as const;\nconst FADE = { duration: 160 } as const;\n/** How far each toast behind the front one peeks out, and how much smaller it is. */\nconst PEEK = 8;\nconst SHRINK = 0.06;\nconst GAP = 8;\nconst EDGE = 16;\nconst FLING_X = 90;\nconst FLING_Y = 28;\n\ntype Position = \"top\" | \"bottom\";\ntype Target = { y: number; scale: number; opacity: number };\ntype Size = { width: number; height: number };\n\nexport type ToasterProps = {\n  /** Where toasts come from. Defaults to \"top\". */\n  position?: Position;\n  /** Space above, such as the top safe-area inset. Defaults to 0. */\n  topInset?: number;\n  /** Space below, such as the bottom safe-area inset. Defaults to 0. */\n  bottomInset?: number;\n  /** How many toasts show at once. Defaults to 3. */\n  visibleToasts?: number;\n  /** Called when a toast appears or changes status, such as for haptics. */\n  onShow?: (toast: ToastItem) => void;\n  /** Moves without motion, independently of the system preference. */\n  disableAnimation?: boolean;\n};\n\n/**\n * Shows the toasts from `toast()`. Mount it once, after your navigator, so it\n * sits above your screens. Toasts stack like notifications: tap the stack to\n * spread it out, swipe one away to dismiss it.\n */\nexport function Toaster({\n  position = \"top\",\n  topInset = 0,\n  bottomInset = 0,\n  visibleToasts = 3,\n  onShow,\n  disableAnimation = false,\n}: ToasterProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const items = useSyncExternalStore(\n    toastStore.subscribe,\n    toastStore.getSnapshot,\n  );\n  const [sizes, setSizes] = useState<Record<string, Size>>({});\n  const [expanded, setExpanded] = useState(false);\n  const [overlayReady, setOverlayReady] = useState(false);\n  if (!overlayReady && items.length > 0) setOverlayReady(true);\n  // Timers hold while the stack is open or a finger is down. Both live in\n  // refs, so a gesture ending and a toggle land in either order.\n  const touching = useRef(false);\n  const open = useRef(false);\n  const syncTimers = useCallback(() => {\n    if (touching.current || open.current) toastStore.pause();\n    else toastStore.resume();\n  }, []);\n\n  // Newest first, and only the ones not leaving.\n  const live = items.filter((item) => !item.dismissed).reverse();\n  if (expanded && live.length <= 1) setExpanded(false);\n\n  useEffect(() => {\n    open.current = expanded;\n    syncTimers();\n  }, [expanded, syncTimers]);\n  const onTouch = useCallback(\n    (down: boolean) => {\n      touching.current = down;\n      syncTimers();\n    },\n    [syncTimers],\n  );\n\n  // Haptics and screen readers hear about new toasts and status changes.\n  const seen = useRef(new Map<string, ToastStatus>());\n  const onShowRef = useRef(onShow);\n  onShowRef.current = onShow;\n  useEffect(() => {\n    for (const item of items) {\n      if (item.dismissed || seen.current.get(item.id) === item.status) continue;\n      seen.current.set(item.id, item.status);\n      onShowRef.current?.(item);\n      if (Platform.OS === \"ios\")\n        AccessibilityInfo.announceForAccessibility(\n          item.description ? `${item.title}. ${item.description}` : item.title,\n        );\n    }\n    for (const id of seen.current.keys())\n      if (!items.some((item) => item.id === id)) seen.current.delete(id);\n  }, [items]);\n\n  const onMeasure = useCallback((id: string, size: Size) => {\n    setSizes((previous) => {\n      const old = previous[id];\n      return old && old.width === size.width && old.height === size.height\n        ? previous\n        : { ...previous, [id]: size };\n    });\n  }, []);\n  const onDismiss = useCallback((id: string) => toast.dismiss(id), []);\n  const onRemove = useCallback((id: string) => {\n    toastStore.remove(id);\n    setSizes(({ [id]: _, ...rest }) => rest);\n  }, []);\n  const onToggle = useCallback(() => {\n    if (live.length <= 1) return;\n    open.current = !open.current;\n    syncTimers();\n    setExpanded(open.current);\n  }, [live.length, syncTimers]);\n\n  const sign = position === \"top\" ? 1 : -1;\n  const front = sizes[live[0]?.id ?? \"\"] ?? { width: 0, height: 44 };\n  const targets = new Map<string, Target & { depth: number }>();\n  let offset = 0;\n  live.forEach((item, depth) => {\n    const height = sizes[item.id]?.height ?? 44;\n    const hidden = depth >= visibleToasts;\n    if (expanded) {\n      targets.set(item.id, {\n        depth,\n        y: sign * offset,\n        scale: 1,\n        opacity: hidden ? 0 : 1,\n      });\n      offset += height + GAP;\n    } else {\n      // Toasts differ in size, so collapsed ones wait unseen behind the\n      // front one, and solid peeks of the front's shape stand in for them.\n      targets.set(item.id, {\n        depth,\n        y: 0,\n        scale: 1 - depth * SHRINK,\n        opacity: depth === 0 ? 1 : 0,\n      });\n    }\n  });\n  const peeks = Math.min(live.length, visibleToasts) - 1;\n\n  const layer = (\n    <View\n      pointerEvents=\"box-none\"\n      style={[\n        StyleSheet.absoluteFill,\n        styles.layer,\n        position === \"top\"\n          ? { paddingTop: topInset + 8 }\n          : { paddingBottom: bottomInset + 8 },\n      ]}\n    >\n      <View\n        pointerEvents=\"box-none\"\n        style={position === \"top\" ? styles.anchorTop : styles.anchorBottom}\n      >\n        {Array.from({ length: Math.max(0, visibleToasts - 1) }, (_, i) => (\n          <Peek\n            key={`peek-${i + 1}`}\n            depth={i + 1}\n            size={front}\n            shown={!expanded && i < peeks}\n            position={position}\n            reduce={reduce}\n          />\n        ))}\n        {items.map((item) => {\n          const target = targets.get(item.id);\n          return (\n            <ToastView\n              key={item.id}\n              item={item}\n              target={target}\n              zIndex={target ? 100 - target.depth : 0}\n              // Collapsed toasts wait unseen behind the front one, so only\n              // the front one takes touches until the stack spreads out.\n              interactive={!!target && (expanded || target.depth === 0)}\n              position={position}\n              reduce={reduce}\n              onMeasure={onMeasure}\n              onDismiss={onDismiss}\n              onRemove={onRemove}\n              onToggle={onToggle}\n              onTouch={onTouch}\n            />\n          );\n        })}\n      </View>\n    </View>\n  );\n\n  // On iOS native tabs and modals draw above React Native views, so toasts\n  // get their own layer on the window. Touches outside a toast pass through.\n  // It mounts with the first toast, once the app's window is up, and then\n  // stays: a remounted overlay stops showing its content.\n  if (Platform.OS === \"ios\") {\n    if (!overlayReady) return null;\n    return (\n      <FullWindowOverlay>\n        <GestureHandlerRootView\n          pointerEvents=\"box-none\"\n          style={StyleSheet.absoluteFill}\n        >\n          {layer}\n        </GestureHandlerRootView>\n      </FullWindowOverlay>\n    );\n  }\n  return layer;\n}\n\nfunction ToastView({\n  item,\n  target,\n  zIndex,\n  interactive,\n  position,\n  reduce,\n  onMeasure,\n  onDismiss,\n  onRemove,\n  onToggle,\n  onTouch,\n}: {\n  item: ToastItem;\n  target?: Target;\n  zIndex: number;\n  interactive: boolean;\n  position: Position;\n  reduce: boolean;\n  onMeasure: (id: string, size: Size) => void;\n  onDismiss: (id: string) => void;\n  onRemove: (id: string) => void;\n  onToggle: () => void;\n  onTouch: (down: boolean) => void;\n}) {\n  const window = useWindowDimensions();\n  const sign = position === \"top\" ? 1 : -1;\n  const enter = useSharedValue(0);\n  const y = useSharedValue(target?.y ?? 0);\n  const scale = useSharedValue(target?.scale ?? 1);\n  const opacity = useSharedValue(target?.opacity ?? 1);\n  const dragX = useSharedValue(0);\n  const dragY = useSharedValue(0);\n  const height = useSharedValue(44);\n  const flung = useSharedValue(0);\n  // The laid-out width, and the width the pill shows at rest, which glides\n  // to a new layout when the status or title changes.\n  const layoutWidth = useSharedValue(0);\n  const restWidth = useSharedValue(0);\n  const measured = useRef(false);\n\n  useEffect(() => {\n    if (!target) return;\n    const config = reduce ? { duration: 0 } : REFLOW;\n    y.value = withTiming(target.y, config);\n    scale.value = withTiming(target.scale, config);\n    opacity.value = withTiming(target.opacity, reduce ? FADE : REFLOW);\n  }, [opacity, reduce, scale, target, y]);\n\n  // Leaving mirrors arriving. A swipe already carried it away.\n  useEffect(() => {\n    if (!item.dismissed || flung.value) return;\n    const id = item.id;\n    enter.value = withTiming(0, reduce ? FADE : EXIT, (done) => {\n      if (done) scheduleOnRN(onRemove, id);\n    });\n  }, [enter, flung, item.dismissed, item.id, onRemove, reduce]);\n\n  // Gesture callbacks run as worklets, so they hand the id to plain functions\n  // from the Toaster; capturing the `toast` object itself crashes worklets.\n  const id = item.id;\n\n  const pan = Gesture.Pan()\n    .enabled(item.dismissible && !item.dismissed)\n    .activeOffsetX([-12, 12])\n    .activeOffsetY([-10, 10])\n    .onBegin(() => {\n      scheduleOnRN(onTouch, true);\n    })\n    .onUpdate((event) => {\n      dragX.value = event.translationX;\n      // Away from the edge resists; toward it follows the finger.\n      const out = event.translationY * sign < 0;\n      dragY.value = out ? event.translationY : event.translationY * 0.15;\n    })\n    .onEnd((event) => {\n      const sideways =\n        Math.abs(event.translationX) > FLING_X ||\n        Math.abs(event.velocityX) > 800;\n      const outward =\n        event.translationY * sign < -FLING_Y || event.velocityY * sign < -500;\n      if (!sideways && !outward) {\n        dragX.value = withTiming(0, REFLOW);\n        dragY.value = withTiming(0, REFLOW);\n        return;\n      }\n      flung.value = 1;\n      scheduleOnRN(onDismiss, id);\n      const done = (finished?: boolean) => {\n        \"worklet\";\n        if (finished) scheduleOnRN(onRemove, id);\n      };\n      if (sideways) {\n        const direction = Math.sign(event.translationX || event.velocityX);\n        dragX.value = withTiming(direction * window.width, EXIT, done);\n      } else {\n        dragY.value = withTiming(-sign * (height.value + 80), EXIT, done);\n      }\n    })\n    .onFinalize(() => {\n      scheduleOnRN(onTouch, false);\n    });\n\n  // The stack's position and depth, plus a quick fade so the morph starts\n  // from the edge. It drops in as a small circle, tucked up by the screen's\n  // edge, and lands in place over the first half of the motion; leaving\n  // plays the same path back.\n  const style = useAnimatedStyle(() => {\n    const e = enter.value;\n    const drop = reduce ? 1 : phase(e, 0, 0.5);\n    const travel = (1 - drop) * -sign * (height.value + DROP_FROM);\n    return {\n      opacity: (reduce ? e : phase(e, 0, 0.18)) * opacity.value,\n      transform: [\n        { translateX: dragX.value },\n        { translateY: y.value + dragY.value + travel },\n        { scale: scale.value * (0.6 + 0.4 * drop) },\n      ],\n    };\n  });\n\n  // As it lands, the pill widens from a circle around the status, out from\n  // the center. Only transforms move: two rounded caps slide apart and the\n  // straight middle stretches between them, so the corners never distort.\n  const shape = useDerivedValue(() => {\n    const full = layoutWidth.value;\n    const rest = restWidth.value;\n    const start = Math.min(rest, Math.max(MORPH_START, height.value));\n    // Widening starts as the drop slows, so the two read as one motion.\n    const grow = reduce ? 1 : phase(enter.value, 0.2, 0.85);\n    const width = start + (rest - start) * grow;\n    const middle = full - CAP * 2;\n    return {\n      stretch: middle > 0 ? Math.max(0, width - CAP * 2) / middle : 1,\n      shift: (full - width) / 2,\n    };\n  });\n  const middleStyle = useAnimatedStyle(() => ({\n    transform: [{ scaleX: shape.value.stretch }],\n  }));\n  // Squeezing also squeezes the middle's shadow into a dark sliver, so its\n  // shadow fades in with its width.\n  const middleShadowStyle = useAnimatedStyle(() => ({\n    opacity: Math.min(1, shape.value.stretch * 3),\n    transform: [{ scaleX: shape.value.stretch }],\n  }));\n  const leftCapStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: shape.value.shift }],\n  }));\n  const rightCapStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: -shape.value.shift }],\n  }));\n  // The status rides the left cap, so it stays centered in the circle.\n  const rowStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: shape.value.shift }],\n  }));\n  // The words come in once the pill has room, and fade out first on the way back.\n  const textStyle = useAnimatedStyle(() => ({\n    opacity: reduce ? 1 : phase(enter.value, 0.55, 0.95),\n  }));\n\n  const onLayout = ({\n    nativeEvent,\n  }: {\n    nativeEvent: { layout: { width: number; height: number } };\n  }) => {\n    const { width, height: tall } = nativeEvent.layout;\n    height.value = tall;\n    layoutWidth.value = width;\n    onMeasure(item.id, { width, height: tall });\n    if (!measured.current) {\n      // The morph waits for the first layout, so it knows its full width.\n      measured.current = true;\n      restWidth.value = width;\n      enter.value = withTiming(1, reduce ? FADE : ENTER);\n      return;\n    }\n    // A new status or title glides the pill to its new width.\n    restWidth.value = reduce ? width : withTiming(width, REFLOW);\n  };\n\n  return (\n    <Animated.View\n      pointerEvents={!item.dismissed && interactive ? \"box-none\" : \"none\"}\n      style={[\n        position === \"top\" ? styles.slotTop : styles.slotBottom,\n        { zIndex },\n        style,\n      ]}\n    >\n      <GestureDetector gesture={pan}>\n        <Pressable\n          onPress={onToggle}\n          onLayout={onLayout}\n          accessibilityRole={item.status === \"error\" ? \"alert\" : \"text\"}\n          accessibilityLiveRegion={\n            item.status === \"error\" ? \"assertive\" : \"polite\"\n          }\n          accessibilityHint={item.dismissible ? \"Swipe to dismiss\" : undefined}\n          style={{ maxWidth: window.width - EDGE * 2 }}\n        >\n          {/* Every piece's shadow goes down before any fill, so the joins\n              disappear and the shadows add up to the whole pill's. */}\n          <PillPieces\n            shadow\n            middle={middleShadowStyle}\n            left={leftCapStyle}\n            right={rightCapStyle}\n          />\n          <PillPieces\n            middle={middleStyle}\n            left={leftCapStyle}\n            right={rightCapStyle}\n          />\n          <Animated.View style={rowStyle}>\n            <ToastContent item={item} reduce={reduce} textStyle={textStyle} />\n          </Animated.View>\n        </Pressable>\n      </GestureDetector>\n    </Animated.View>\n  );\n}\n\ntype PieceStyle = ReturnType<typeof useAnimatedStyle>;\n\n/** The pill as two rounded caps and a straight middle that stretches. */\nfunction PillPieces({\n  shadow = false,\n  middle,\n  left,\n  right,\n}: {\n  shadow?: boolean;\n  middle: PieceStyle;\n  left: PieceStyle;\n  right: PieceStyle;\n}) {\n  // The shadow pieces only cast; the fills on top carry the border.\n  const look = shadow ? styles.shadow : undefined;\n  const edge = (sides: string) =>\n    shadow ? \"bg-background\" : `${sides} border-border bg-background`;\n  return (\n    <>\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[styles.middle, look, middle]}\n        className={edge(\"border-y\")}\n      />\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[styles.capLeft, look, left]}\n        className={edge(\"border-y border-l\")}\n      />\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[styles.capRight, look, right]}\n        className={edge(\"border-y border-r\")}\n      />\n    </>\n  );\n}\n\n/** The row inside a toast: status, words and an optional action. */\nfunction ToastContent({\n  item,\n  reduce,\n  textStyle,\n}: {\n  item: ToastItem;\n  reduce: boolean;\n  textStyle?: ReturnType<typeof useAnimatedStyle>;\n}) {\n  return (\n    <View className=\"flex-row items-center gap-2.5 py-2.5 pl-2.5 pr-4\">\n      <StatusBadge status={item.status} reduce={reduce} />\n      <Animated.View\n        style={textStyle}\n        className=\"shrink flex-row items-center gap-2.5\"\n      >\n        <View className=\"shrink\">\n          <RollText\n            text={item.title}\n            className=\"text-sm font-medium text-foreground\"\n            reduce={reduce}\n            numberOfLines={2}\n          />\n          {item.description ? (\n            <Text numberOfLines={3} className=\"text-xs text-muted\">\n              {item.description}\n            </Text>\n          ) : null}\n        </View>\n        {item.action ? (\n          <Pressable\n            accessibilityRole=\"button\"\n            hitSlop={8}\n            onPress={() => {\n              item.action?.onPress();\n              toast.dismiss(item.id);\n            }}\n            className=\"ml-1 h-7 items-center justify-center rounded-full bg-primary px-3 active:opacity-80\"\n          >\n            <Text className=\"text-xs font-medium text-on-primary\">\n              {item.action.label}\n            </Text>\n          </Pressable>\n        ) : null}\n      </Animated.View>\n    </View>\n  );\n}\n\n/**\n * A solid edge of the front toast's shape, peeking out behind it to show\n * more toasts are waiting. It follows the front toast's size.\n */\nfunction Peek({\n  depth,\n  size,\n  shown,\n  position,\n  reduce,\n}: {\n  depth: number;\n  size: Size;\n  shown: boolean;\n  position: Position;\n  reduce: boolean;\n}) {\n  const sign = position === \"top\" ? 1 : -1;\n  const width = useSharedValue(size.width);\n  const height = useSharedValue(size.height);\n  const visible = useSharedValue(shown ? 1 : 0);\n\n  useEffect(() => {\n    const config = reduce ? { duration: 0 } : REFLOW;\n    width.value = withTiming(size.width, config);\n    height.value = withTiming(size.height, config);\n  }, [height, reduce, size.height, size.width, width]);\n  useEffect(() => {\n    visible.value = withTiming(shown ? 1 : 0, reduce ? FADE : REFLOW);\n  }, [reduce, shown, visible]);\n\n  const style = useAnimatedStyle(() => ({\n    width: width.value,\n    height: height.value,\n    opacity: visible.value,\n    transform: [\n      { translateY: sign * depth * PEEK * visible.value },\n      { scale: 1 - depth * SHRINK },\n    ],\n  }));\n\n  return (\n    <View\n      pointerEvents=\"none\"\n      style={[\n        position === \"top\" ? styles.slotTop : styles.slotBottom,\n        { zIndex: 100 - depth },\n      ]}\n    >\n      <Animated.View\n        style={[\n          styles.pill,\n          { transformOrigin: position === \"top\" ? \"50% 100%\" : \"50% 0%\" },\n          style,\n        ]}\n        className=\"border border-border bg-background\"\n      />\n    </View>\n  );\n}\n\nconst BADGE: Record<\n  Exclude<ToastStatus, \"neutral\" | \"loading\">,\n  { className: string; glyph: (color: string) => ReactNode }\n> = {\n  success: {\n    className: \"bg-primary\",\n    glyph: (color) => <CheckGlyph size={14} color={color} strokeWidth={2.8} />,\n  },\n  info: {\n    className: \"bg-primary\",\n    glyph: (color) => <InfoGlyph size={14} color={color} strokeWidth={2.8} />,\n  },\n  error: {\n    className: \"bg-destructive\",\n    glyph: (color) => (\n      <ExclaimGlyph size={14} color={color} strokeWidth={2.8} />\n    ),\n  },\n};\n\n/** The status in a filled circle; it crossfades when the status changes. */\nfunction StatusBadge({\n  status,\n  reduce,\n}: {\n  status: ToastStatus;\n  reduce: boolean;\n}) {\n  const { color: onPrimary } = useResolveClassNames(\"text-on-primary\");\n  const { color: primary } = useResolveClassNames(\"text-primary\");\n  const shown = useSharedValue(1);\n  const [current, setCurrent] = useState(status);\n\n  // Fade the old mark out, swap, fade the new one in.\n  useEffect(() => {\n    if (status === current) return;\n    if (reduce) {\n      setCurrent(status);\n      return;\n    }\n    shown.value = withTiming(0, { duration: 90 }, (done) => {\n      if (done) scheduleOnRN(setCurrent, status);\n    });\n  }, [current, reduce, shown, status]);\n  useEffect(() => {\n    shown.value = withTiming(1, { duration: 160, easing: EASE_OUT });\n  }, [current, shown]);\n\n  const style = useAnimatedStyle(() => ({\n    opacity: shown.value,\n    transform: [{ scale: 0.7 + 0.3 * shown.value }],\n  }));\n\n  if (current === \"neutral\" && status === \"neutral\") return null;\n\n  return (\n    <Animated.View\n      style={[styles.badge, style]}\n      className={\n        current === \"loading\" || current === \"neutral\"\n          ? \"bg-primary/10\"\n          : BADGE[current].className\n      }\n    >\n      {current === \"loading\" ? (\n        <SpinGlyph size={14} color={primary as string} reduce={reduce} />\n      ) : current === \"neutral\" ? null : (\n        BADGE[current].glyph(onPrimary as string)\n      )}\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  layer: { zIndex: 1000, elevation: 1000 },\n  anchorTop: { flex: 1 },\n  anchorBottom: { flex: 1, justifyContent: \"flex-end\" },\n  slotTop: {\n    position: \"absolute\",\n    top: 0,\n    left: EDGE,\n    right: EDGE,\n    alignItems: \"center\",\n  },\n  slotBottom: {\n    position: \"absolute\",\n    bottom: 0,\n    left: EDGE,\n    right: EDGE,\n    alignItems: \"center\",\n  },\n  pill: { borderRadius: CAP, minHeight: 44, boxShadow: SHADOW },\n  shadow: { boxShadow: SHADOW },\n  middle: { position: \"absolute\", top: 0, bottom: 0, left: CAP, right: CAP },\n  capLeft: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: 0,\n    width: CAP,\n    borderTopLeftRadius: CAP,\n    borderBottomLeftRadius: CAP,\n  },\n  capRight: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    right: 0,\n    width: CAP,\n    borderTopRightRadius: CAP,\n    borderBottomRightRadius: CAP,\n  },\n  badge: {\n    width: 24,\n    height: 24,\n    borderRadius: 12,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\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\n/** An exclamation mark alone, for a filled badge. */\nexport function ExclaimGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 6.5v7M12 17.5h.01\" />\n    </Glyph>\n  );\n}\n\n/** An \"i\" alone, for a filled badge. */\nexport function InfoGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 11v6.5M12 6.5h.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/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/primitives/spin-glyph.tsx","content":"import { useEffect } from \"react\";\nimport type { ColorValue } from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  Easing,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { RingGlyph } from \"./agent-icons\";\n\n/**\n * A spinning ring for busy states. Reduced motion holds it still; the ring\n * still reads as \"in progress\".\n */\nexport function SpinGlyph({\n  size = 16,\n  color,\n  reduce,\n}: {\n  size?: number;\n  color?: ColorValue;\n  reduce: boolean;\n}) {\n  const turn = useSharedValue(0);\n\n  useEffect(() => {\n    if (reduce) {\n      cancelAnimation(turn);\n      turn.value = 0;\n      return;\n    }\n    turn.value = 0;\n    turn.value = withRepeat(\n      withTiming(1, {\n        duration: 900,\n        easing: Easing.linear,\n        reduceMotion: ReduceMotion.Never,\n      }),\n      -1,\n      false,\n    );\n    return () => cancelAnimation(turn);\n  }, [reduce, turn]);\n\n  const style = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 360}deg` }],\n  }));\n\n  return (\n    <Animated.View style={style}>\n      <RingGlyph size={size} color={color} />\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"}]}