{"slug":"approval-card","name":"Approval Card","description":"Swipeable questions, or a review to approve.","group":"AI agents","detail_url":"https://beui.dev/native/r/approval-card","page_url":"https://beui.dev/react-native/approval-card","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/approval-card?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/approval-card","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/approval-card/approval-card.tsx","content":"import { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport {\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  ArrowGlyph,\n  CheckGlyph,\n  CrossGlyph,\n  HelpGlyph,\n  MessageGlyph,\n} from \"../../primitives/agent-icons\";\nimport { Disclosure } from \"../../primitives/disclosure\";\nimport type { IconProps } from \"../../primitives/render-icon\";\nimport { RollText } from \"../../primitives/roll-text\";\nimport { SpinGlyph } from \"../../primitives/spin-glyph\";\nimport { EASE_OUT, SPRING_SWAP } from \"../../styles/motion\";\nimport { Button } from \"../button/button\";\nimport { Checkbox } from \"../checkbox/checkbox\";\nimport { Input } from \"../input/input\";\nimport { RadioGroup, RadioGroupItem } from \"../radio/radio\";\n\nexport type ApprovalCardStatus =\n  | \"pending\"\n  | \"submitting\"\n  | \"approved\"\n  | \"rejected\"\n  | \"changes-requested\"\n  | \"answered\";\n\nexport type ApprovalCardOption = {\n  value: string;\n  label: string;\n  description?: string;\n  disabled?: boolean;\n};\n\nexport type ApprovalCardQuestion = {\n  id: string;\n  title: string;\n  description?: string;\n  options?: ApprovalCardOption[];\n  /** Checkboxes instead of radios. */\n  multiple?: boolean;\n  /** Moves on after a single choice. Defaults to true for single-choice questions. */\n  autoAdvance?: boolean;\n  /** Adds a free-text answer. With no options, the question is free text only. */\n  allowCustom?: boolean;\n  customPlaceholder?: string;\n};\n\nexport type ApprovalCardAnswer = { selected: string[]; custom?: string };\nexport type ApprovalCardAnswers = Record<string, ApprovalCardAnswer>;\n\nconst EMPTY: ApprovalCardAnswer = { selected: [], custom: \"\" };\nconst ADVANCE_AFTER = 240;\nconst SWAP_IN = { duration: 200, easing: EASE_OUT };\n\nconst STATUS_LABEL: Record<ApprovalCardStatus, string> = {\n  pending: \"Input required\",\n  submitting: \"Submitting\",\n  approved: \"Approved\",\n  rejected: \"Rejected\",\n  \"changes-requested\": \"Changes requested\",\n  answered: \"Response submitted\",\n};\nconst TONE: Record<ApprovalCardStatus, string> = {\n  pending: \"text-muted\",\n  submitting: \"text-muted\",\n  approved: \"text-success\",\n  answered: \"text-success\",\n  rejected: \"text-destructive\",\n  \"changes-requested\": \"text-warning\",\n};\n// Full class strings, so Uniwind can see every token at build time.\nconst BADGE_CLASS: Record<ApprovalCardStatus, string> = {\n  pending: \"border-warning/30 bg-warning/10\",\n  \"changes-requested\": \"border-warning/30 bg-warning/10\",\n  submitting: \"border-primary/30 bg-primary/10\",\n  approved: \"border-success/30 bg-success/10\",\n  answered: \"border-success/30 bg-success/10\",\n  rejected: \"border-destructive/30 bg-destructive/10\",\n};\nconst BADGE_TEXT: Record<ApprovalCardStatus, string> = {\n  pending: \"text-warning\",\n  \"changes-requested\": \"text-warning\",\n  submitting: \"text-primary\",\n  approved: \"text-success\",\n  answered: \"text-success\",\n  rejected: \"text-destructive\",\n};\n\nconst answered = (answer: ApprovalCardAnswer) =>\n  answer.selected.length > 0 || Boolean(answer.custom?.trim());\n\nfunction BusyIcon({ size, color }: IconProps) {\n  const reduce = useReducedMotionPreference();\n  return <SpinGlyph size={size} color={color} reduce={reduce} />;\n}\nfunction NextIcon({ size, color }: IconProps) {\n  return <ArrowGlyph size={size} color={color} />;\n}\nfunction BackIcon({ size, color }: IconProps) {\n  return <ArrowGlyph back size={size} color={color} />;\n}\n\nfunction Dot({\n  index,\n  current,\n  reduce,\n}: {\n  index: number;\n  current: number;\n  reduce: boolean;\n}) {\n  const scale = useSharedValue(index === current ? 1 : 0.75);\n  const opacity = useSharedValue(index <= current ? 1 : 0.35);\n  useEffect(() => {\n    const s = index === current ? 1 : 0.75;\n    const o = index <= current ? 1 : 0.35;\n    scale.value = reduce ? s : withSpring(s, SPRING_SWAP);\n    opacity.value = reduce ? o : withSpring(o, SPRING_SWAP);\n  }, [current, index, opacity, reduce, scale]);\n  const style = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ scale: scale.value }],\n  }));\n  return <Animated.View style={[styles.dot, style]} className=\"bg-primary\" />;\n}\n\nconst SLIDE = 24;\nconst SWIPE = 56;\nconst RESIST = 0.3;\n\n/** Progress runs 0 (arriving) → 1 (resting) → 2 (leaving). */\nfunction QuestionLayer({\n  appear,\n  exiting,\n  direction,\n  reduce,\n  children,\n}: {\n  appear: boolean;\n  exiting: boolean;\n  direction: number;\n  reduce: boolean;\n  children: ReactNode;\n}) {\n  const progress = useSharedValue(appear && !reduce ? 0 : 1);\n  useEffect(() => {\n    progress.value = withTiming(\n      exiting ? 2 : 1,\n      reduce ? { duration: 0 } : SWAP_IN,\n    );\n  }, [exiting, progress, reduce]);\n  const style = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: p <= 1 ? p : 2 - p,\n      // Arrives from the side you're heading toward; leaves out the other.\n      transform: [{ translateX: reduce ? 0 : (1 - p) * SLIDE * direction }],\n    };\n  }, [direction, reduce]);\n  return (\n    <Animated.View\n      pointerEvents={exiting ? \"none\" : \"auto\"}\n      style={[exiting && styles.leaving, style]}\n    >\n      {children}\n    </Animated.View>\n  );\n}\n\nexport type ApprovalCardProps = Omit<ViewProps, \"children\"> & {\n  /** Defaults to \"Approval required\". Questions show their own titles. */\n  title?: string;\n  /** Review mode only. */\n  description?: string;\n  /** Review mode only: what's being approved, such as a summary of changes. */\n  children?: ReactNode;\n  /** Asks these one at a time. Without questions, the card asks for a review. */\n  questions?: ApprovalCardQuestion[];\n  /** Defaults to \"pending\". You move it forward after a decision. */\n  status?: ApprovalCardStatus;\n  answers?: ApprovalCardAnswers;\n  defaultAnswers?: ApprovalCardAnswers;\n  onAnswersChange?: (answers: ApprovalCardAnswers) => void;\n  step?: number;\n  /** Defaults to 0. */\n  defaultStep?: number;\n  onStepChange?: (step: number) => void;\n  /** Called with every answer after the last question. */\n  onSubmit?: (answers: ApprovalCardAnswers) => void;\n  onApprove?: () => void;\n  /** Review mode: shows \"Reject\". */\n  onReject?: () => void;\n  /** Review mode: shows \"Request changes\". */\n  onRequestChanges?: () => void;\n  /** Shows a dismiss button. */\n  onDismiss?: () => void;\n  /** Defaults to \"Approve\". */\n  approveLabel?: string;\n  /** Defaults to \"Submit response\". */\n  submitLabel?: string;\n  /** Shown once decided, in place of the status label. */\n  result?: string;\n  className?: string;\n  disableAnimation?: boolean;\n};\n\n/**\n * Asks a person to weigh in before an agent continues: a few questions one\n * at a time, or a review to approve. Once decided, it folds into the result.\n */\nexport function ApprovalCard({\n  title = \"Approval required\",\n  description,\n  children,\n  questions = [],\n  status = \"pending\",\n  answers: controlledAnswers,\n  defaultAnswers = {},\n  onAnswersChange,\n  step: controlledStep,\n  defaultStep = 0,\n  onStepChange,\n  onSubmit,\n  onApprove,\n  onReject,\n  onRequestChanges,\n  onDismiss,\n  approveLabel = \"Approve\",\n  submitLabel = \"Submit response\",\n  result,\n  className = \"\",\n  disableAnimation = false,\n  ...props\n}: ApprovalCardProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internalAnswers, setInternalAnswers] = useState(defaultAnswers);\n  const [internalStep, setInternalStep] = useState(defaultStep);\n  const answers = controlledAnswers ?? internalAnswers;\n  const hasQuestions = questions.length > 0;\n  const step = Math.min(\n    Math.max(controlledStep ?? internalStep, 0),\n    Math.max(questions.length - 1, 0),\n  );\n  const question = hasQuestions ? questions[step] : undefined;\n  const answer = (question && answers[question.id]) || EMPTY;\n  const busy = status === \"submitting\";\n  const interactive = status === \"pending\" || busy;\n  const last = step === questions.length - 1;\n  const advanceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(\n    undefined,\n  );\n  const { color: tone } = useResolveClassNames(TONE[status]);\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n\n  useEffect(() => () => clearTimeout(advanceTimer.current), []);\n\n  const direction = useRef(1);\n  const drag = useSharedValue(0);\n  const stage = usePresence(\n    question?.id ?? null,\n    question as ApprovalCardQuestion,\n    () => (reduce ? 0 : SWAP_IN.duration + 40),\n  );\n\n  const setStep = (next: number) => {\n    clearTimeout(advanceTimer.current);\n    direction.current = next >= step ? 1 : -1;\n    if (controlledStep === undefined) setInternalStep(next);\n    onStepChange?.(next);\n  };\n  const setAnswer = (next: ApprovalCardAnswer) => {\n    if (!question) return;\n    const all = { ...answers, [question.id]: next };\n    if (controlledAnswers === undefined) setInternalAnswers(all);\n    onAnswersChange?.(all);\n  };\n  const advance = () => {\n    if (!last) setStep(step + 1);\n    else onSubmit?.(answers);\n  };\n  const queueAdvance = () => {\n    if (\n      !question ||\n      question.multiple ||\n      question.autoAdvance === false ||\n      last ||\n      busy\n    )\n      return;\n    clearTimeout(advanceTimer.current);\n    advanceTimer.current = setTimeout(() => setStep(step + 1), ADVANCE_AFTER);\n  };\n\n  const renderQuestion = (entry: ApprovalCardQuestion) => {\n    const entryAnswer = answers[entry.id] ?? EMPTY;\n    return (\n      <>\n        {entry.description ? (\n          <Text className=\"mt-1 text-sm leading-5 text-muted\">\n            {entry.description}\n          </Text>\n        ) : null}\n        <View className=\"mt-3\">\n          {entry.options?.length ? (\n            entry.multiple ? (\n              <View className=\"gap-0.5\">\n                {entry.options.map((option) => (\n                  <Checkbox\n                    key={option.value}\n                    label={option.label}\n                    description={option.description}\n                    disabled={busy || option.disabled}\n                    checked={entryAnswer.selected.includes(option.value)}\n                    onCheckedChange={(checked) =>\n                      setAnswer({\n                        selected: checked\n                          ? [...entryAnswer.selected, option.value]\n                          : entryAnswer.selected.filter(\n                              (value) => value !== option.value,\n                            ),\n                        custom: entryAnswer.custom,\n                      })\n                    }\n                    className=\"min-h-9 rounded-lg px-1.5 py-1\"\n                  />\n                ))}\n              </View>\n            ) : (\n              <RadioGroup\n                value={entryAnswer.selected[0] ?? \"\"}\n                onValueChange={(value) => {\n                  setAnswer({ selected: [value], custom: \"\" });\n                  queueAdvance();\n                }}\n                className=\"gap-0.5\"\n              >\n                {entry.options.map((option) => (\n                  <RadioGroupItem\n                    key={option.value}\n                    value={option.value}\n                    label={option.label}\n                    description={option.description}\n                    disabled={busy || option.disabled}\n                    className=\"min-h-9 rounded-lg px-1.5 py-1\"\n                  />\n                ))}\n              </RadioGroup>\n            )\n          ) : null}\n          {entry.allowCustom ? (\n            <Input\n              value={entryAnswer.custom ?? \"\"}\n              onChangeText={(custom) =>\n                setAnswer({\n                  selected: entry.multiple ? entryAnswer.selected : [],\n                  custom,\n                })\n              }\n              placeholder={entry.customPlaceholder ?? \"Add another response…\"}\n              accessibilityLabel={entry.customPlaceholder ?? \"Another response\"}\n              disabled={busy}\n              returnKeyType={last ? \"send\" : \"next\"}\n              onSubmitEditing={() => {\n                if (answered(answer)) advance();\n              }}\n              className={entry.options?.length ? \"mt-1.5\" : \"\"}\n              classNames={{\n                field: \"h-11 rounded-xl border-0 bg-background/70\",\n                input: \"px-3 text-sm\",\n              }}\n            />\n          ) : null}\n        </View>\n      </>\n    );\n  };\n\n  const canForward = !busy && !last && answered(answer);\n  const canBack = !busy && step > 0;\n  const goForward = () => setStep(step + 1);\n  const goBack = () => setStep(step - 1);\n  // Swipe left for the next question, right for the previous one. A swipe\n  // that can't go anywhere pulls with resistance and springs back.\n  const swipe = Gesture.Pan()\n    .enabled(questions.length > 1)\n    .activeOffsetX([-16, 16])\n    .failOffsetY([-12, 12])\n    .onUpdate((event) => {\n      const allowed = event.translationX < 0 ? canForward : canBack;\n      drag.value = event.translationX * (allowed ? 0.5 : RESIST * 0.5);\n    })\n    .onEnd((event) => {\n      if (event.translationX < -SWIPE && canForward) scheduleOnRN(goForward);\n      else if (event.translationX > SWIPE && canBack) scheduleOnRN(goBack);\n      drag.value = withSpring(0, SPRING_SWAP);\n    });\n  const dragStyle = useAnimatedStyle(() => ({\n    transform: [{ translateX: drag.value }],\n  }));\n\n  const statusIcon = busy ? (\n    <SpinGlyph size={16} color={tone} reduce={reduce} />\n  ) : interactive ? (\n    hasQuestions ? (\n      <HelpGlyph size={16} color={tone} />\n    ) : (\n      <MessageGlyph size={16} color={tone} />\n    )\n  ) : status === \"rejected\" ? (\n    <CrossGlyph size={16} color={tone} />\n  ) : (\n    <CheckGlyph size={16} color={tone} />\n  );\n\n  return (\n    <View\n      accessibilityState={{ busy }}\n      className={`w-full overflow-hidden rounded-2xl bg-surface p-4 ${className}`}\n      {...props}\n    >\n      <View style={styles.layout}>\n        <View style={styles.icon}>{statusIcon}</View>\n        <View style={styles.flex}>\n          <View style={styles.titleRow}>\n            <View style={styles.flex} accessibilityRole=\"header\">\n              <RollText\n                value={question?.id ?? status}\n                text={question?.title ?? title}\n                className=\"text-base font-medium leading-5 text-foreground\"\n                reduce={reduce}\n                numberOfLines={0}\n              />\n            </View>\n            {hasQuestions && interactive ? (\n              questions.length > 1 ? (\n                <Text\n                  accessibilityLabel={`Question ${step + 1} of ${questions.length}`}\n                  className=\"text-xs text-muted/70\"\n                  style={styles.tabular}\n                >\n                  {step + 1}/{questions.length}\n                </Text>\n              ) : null\n            ) : (\n              <View\n                accessibilityLiveRegion=\"polite\"\n                className={`rounded-full border px-2 py-0.5 ${BADGE_CLASS[status]}`}\n              >\n                <Text\n                  className={`text-[11px] font-medium ${BADGE_TEXT[status]}`}\n                >\n                  {STATUS_LABEL[status]}\n                </Text>\n              </View>\n            )}\n            {onDismiss ? (\n              <Pressable\n                accessibilityRole=\"button\"\n                accessibilityLabel=\"Dismiss\"\n                hitSlop={12}\n                onPress={onDismiss}\n              >\n                <CrossGlyph size={16} color={muted} />\n              </Pressable>\n            ) : null}\n          </View>\n\n          <Disclosure open={interactive} reduce={reduce}>\n            {question ? (\n              <View>\n                <GestureDetector gesture={swipe}>\n                  <Animated.View style={dragStyle}>\n                    {stage.map((item) => (\n                      <QuestionLayer\n                        key={item.key}\n                        appear={item.appear}\n                        exiting={item.exiting}\n                        direction={direction.current}\n                        reduce={reduce}\n                      >\n                        {renderQuestion(item.value)}\n                      </QuestionLayer>\n                    ))}\n                  </Animated.View>\n                </GestureDetector>\n                <View style={styles.footer} className=\"mt-4\">\n                  {questions.length > 1 ? (\n                    <>\n                      <Button\n                        size=\"icon\"\n                        variant=\"ghost\"\n                        icon={BackIcon}\n                        accessibilityLabel=\"Previous question\"\n                        disabled={step === 0 || busy}\n                        onPress={() => setStep(step - 1)}\n                        className=\"rounded-full\"\n                      />\n                      <View\n                        accessible\n                        accessibilityLabel={`Question ${step + 1} of ${questions.length}`}\n                        style={styles.dots}\n                      >\n                        {questions.map((entry, index) => (\n                          <Dot\n                            key={entry.id}\n                            index={index}\n                            current={step}\n                            reduce={reduce}\n                          />\n                        ))}\n                      </View>\n                    </>\n                  ) : null}\n                  <View style={styles.next}>\n                    {last ? (\n                      <Button\n                        size=\"sm\"\n                        endIcon={busy ? BusyIcon : NextIcon}\n                        disabled={busy || !answered(answer)}\n                        onPress={advance}\n                        className=\"rounded-full\"\n                      >\n                        {submitLabel}\n                      </Button>\n                    ) : (\n                      <Button\n                        size=\"icon\"\n                        icon={NextIcon}\n                        accessibilityLabel=\"Next question\"\n                        disabled={busy || !answered(answer)}\n                        onPress={advance}\n                        className=\"rounded-full\"\n                      />\n                    )}\n                  </View>\n                </View>\n              </View>\n            ) : (\n              <View>\n                {description ? (\n                  <Text className=\"mt-1 text-sm leading-5 text-muted\">\n                    {description}\n                  </Text>\n                ) : null}\n                {children ? <View className=\"mt-3\">{children}</View> : null}\n                <View style={styles.review} className=\"mt-4\">\n                  <Button\n                    size=\"sm\"\n                    disabled={busy}\n                    onPress={onApprove}\n                    className=\"rounded-full\"\n                  >\n                    {approveLabel}\n                  </Button>\n                  {onRequestChanges ? (\n                    <Button\n                      size=\"sm\"\n                      variant=\"secondary\"\n                      disabled={busy}\n                      onPress={onRequestChanges}\n                      className=\"rounded-full\"\n                    >\n                      Request changes\n                    </Button>\n                  ) : null}\n                  {onReject ? (\n                    <Button\n                      size=\"sm\"\n                      variant=\"ghost\"\n                      disabled={busy}\n                      onPress={onReject}\n                      className=\"rounded-full\"\n                    >\n                      Reject\n                    </Button>\n                  ) : null}\n                </View>\n              </View>\n            )}\n          </Disclosure>\n          {!interactive ? (\n            <Text className=\"mt-1 text-sm text-muted\">\n              {result ?? STATUS_LABEL[status]}\n            </Text>\n          ) : null}\n        </View>\n      </View>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  flex: { flex: 1, minWidth: 0 },\n  layout: { flexDirection: \"row\", alignItems: \"flex-start\", gap: 12 },\n  icon: {\n    width: 20,\n    height: 20,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  titleRow: { flexDirection: \"row\", alignItems: \"flex-start\", gap: 12 },\n  footer: { flexDirection: \"row\", alignItems: \"center\", gap: 12 },\n  dots: { flexDirection: \"row\", gap: 6 },\n  dot: { width: 6, height: 6, borderRadius: 3 },\n  next: { marginLeft: \"auto\" },\n  review: {\n    flexDirection: \"row\",\n    flexWrap: \"wrap\",\n    alignItems: \"center\",\n    gap: 8,\n  },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n  leaving: { position: \"absolute\", left: 0, right: 0, top: 0 },\n});\n","type":"component"},{"path":"src/components/approval-card/index.ts","content":"export type {\n  ApprovalCardAnswer,\n  ApprovalCardAnswers,\n  ApprovalCardOption,\n  ApprovalCardProps,\n  ApprovalCardQuestion,\n  ApprovalCardStatus,\n} from \"./approval-card\";\nexport { ApprovalCard } from \"./approval-card\";\n","type":"component"},{"path":"src/components/button/button.styles.ts","content":"import type { ButtonSize, ButtonVariant } from \"./button.types\";\n\n// Class strings stay literal so Uniwind can discover them.\n\nexport const variants: Record<\n  ButtonVariant,\n  { container: string; label: string }\n> = {\n  primary: { container: \"bg-primary border-primary\", label: \"text-on-primary\" },\n  secondary: {\n    container: \"bg-surface border-border\",\n    label: \"text-foreground\",\n  },\n  ghost: {\n    container: \"bg-transparent border-transparent\",\n    label: \"text-muted\",\n  },\n  outline: {\n    container: \"bg-transparent border-border\",\n    label: \"text-foreground\",\n  },\n};\n\n/**\n * Visible heights match beUI web (32, 40, 48). `hitSlop` tops the touch area up\n * to 48 points; content may grow taller for larger text.\n */\nexport const sizes: Record<\n  ButtonSize,\n  {\n    container: string;\n    radius: string;\n    label: string;\n    icon: number;\n    gap: number;\n    hitSlop: number;\n  }\n> = {\n  sm: {\n    container: \"min-h-8 px-3 py-1 gap-1.5\",\n    radius: \"rounded-full\",\n    label: \"text-xs\",\n    icon: 14,\n    gap: 6,\n    hitSlop: 8,\n  },\n  md: {\n    container: \"min-h-10 px-5 py-2 gap-2\",\n    radius: \"rounded-full\",\n    label: \"text-sm\",\n    icon: 16,\n    gap: 8,\n    hitSlop: 4,\n  },\n  lg: {\n    container: \"min-h-12 px-6 py-2 gap-2\",\n    radius: \"rounded-full\",\n    label: \"text-base\",\n    icon: 18,\n    gap: 8,\n    hitSlop: 0,\n  },\n  icon: {\n    container: \"h-8 w-8\",\n    radius: \"rounded-lg\",\n    label: \"text-sm\",\n    icon: 16,\n    gap: 0,\n    hitSlop: 8,\n  },\n};\n","type":"shared"},{"path":"src/components/button/button.tsx","content":"import { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport {\n  type ColorValue,\n  Pressable,\n  StyleSheet,\n  Text,\n  type TextProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { renderIcon } from \"../../primitives/render-icon\";\nimport { EASE_OUT, motion } from \"../../styles/motion\";\nimport { sizes, variants } from \"./button.styles\";\nimport type {\n  ButtonIcon as ButtonIconType,\n  ButtonProps,\n  ButtonRootProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./button.types\";\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\nconst HOVER_SCALE = 1.02;\nconst RIPPLE_DURATION = 1600;\n\ntype ButtonContextValue = {\n  variant: ButtonVariant;\n  size: ButtonSize;\n  color: ColorValue | undefined;\n};\n\nconst ButtonContext = createContext<ButtonContextValue | null>(null);\n\n/** Internal: size, variant and resolved foreground color of the enclosing button. */\nexport function useButtonContext() {\n  const context = useContext(ButtonContext);\n  if (!context) throw new Error(\"Button content must be rendered in a Button.\");\n  return context;\n}\n\nexport { renderIcon as renderButtonIcon };\n\nfunction ButtonLabel({ className = \"\", ...props }: TextProps) {\n  const { variant, size } = useButtonContext();\n  return (\n    <Text\n      {...props}\n      className={`shrink text-center font-medium ${sizes[size].label} ${variants[variant].label} ${className}`}\n    />\n  );\n}\n\nfunction ButtonIcon({ icon }: { icon: ButtonIconType }) {\n  const { size, color } = useButtonContext();\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      aria-hidden\n      style={styles.icon}\n    >\n      {renderIcon(icon, { size: sizes[size].icon, color })}\n    </View>\n  );\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nfunction RippleDot({ ripple, color }: { ripple: Ripple; color: ColorValue }) {\n  const progress = useSharedValue(0);\n\n  useEffect(() => {\n    progress.value = withTiming(1, {\n      duration: RIPPLE_DURATION,\n      easing: EASE_OUT,\n    });\n  }, [progress]);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: 0.3 * (1 - progress.value),\n    transform: [{ scale: 0.05 + 0.95 * progress.value }],\n  }));\n\n  return (\n    <Animated.View\n      style={[\n        styles.ripple,\n        {\n          left: ripple.x - ripple.size / 2,\n          top: ripple.y - ripple.size / 2,\n          width: ripple.size,\n          height: ripple.size,\n          borderRadius: ripple.size / 2,\n          backgroundColor: color,\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\n/** Internal: the pressable surface, rendering arbitrary content. */\nexport function ButtonRoot({\n  variant = \"primary\",\n  size = \"md\",\n  pressScale = 0.93,\n  ripple = false,\n  disableAnimation = false,\n  disabled: disabledProp,\n  children,\n  className = \"\",\n  style,\n  hitSlop,\n  accessibilityState,\n  onPressIn,\n  onPressOut,\n  onHoverIn,\n  onHoverOut,\n  ref,\n  ...props\n}: ButtonRootProps) {\n  const disabled = disabledProp ?? false;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const appearance = variants[variant];\n  const dimensions = sizes[size];\n  const { color } = useResolveClassNames(appearance.label);\n\n  const scale = useSharedValue(1);\n  const pressed = useSharedValue(false);\n  const hovered = useSharedValue(false);\n  const layout = useRef({ width: 0, height: 0 });\n  const nextRippleId = useRef(0);\n  const rippleTimers = useRef(new Set<ReturnType<typeof setTimeout>>());\n  const [ripples, setRipples] = useState<Ripple[]>([]);\n\n  useEffect(() => {\n    const timers = rippleTimers.current;\n    return () => {\n      for (const timer of timers) clearTimeout(timer);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!animate || disabled) scale.value = 1;\n  }, [animate, disabled, scale]);\n\n  const animatedStyle = useAnimatedStyle(\n    () => ({\n      transform: [{ scale: scale.value }],\n      // Without scale, pressing still needs visible feedback.\n      opacity: disabled ? 0.5 : !animate && pressed.value ? 0.7 : 1,\n    }),\n    [animate, disabled],\n  );\n\n  const restScale = () => (hovered.value ? HOVER_SCALE : 1);\n\n  return (\n    <ButtonContext.Provider value={{ variant, size, color }}>\n      <AnimatedPressable\n        {...props}\n        ref={ref}\n        accessible\n        accessibilityRole=\"button\"\n        accessibilityState={{ ...accessibilityState, disabled }}\n        aria-disabled={disabled}\n        disabled={disabled}\n        hitSlop={hitSlop ?? dimensions.hitSlop}\n        // Hugs its content unless the caller places it with a self-* class; on web,\n        // a default self-start would otherwise win over the caller's.\n        className={`${/\\bself-/.test(className) ? \"\" : \"self-start\"} flex-row items-center justify-center border ${appearance.container} ${dimensions.container} ${dimensions.radius} ${className}`}\n        style={[style, animatedStyle]}\n        onPressIn={(event) => {\n          pressed.value = true;\n          if (animate) scale.value = withSpring(pressScale, motion.press);\n          if (ripple && animate && color) {\n            const { locationX, locationY } = event.nativeEvent;\n            const { width, height } = layout.current;\n            const id = nextRippleId.current++;\n            setRipples((current) => [\n              ...current,\n              {\n                id,\n                x: locationX,\n                y: locationY,\n                size: Math.max(width, height) * 2,\n              },\n            ]);\n            const timer = setTimeout(() => {\n              rippleTimers.current.delete(timer);\n              setRipples((current) => current.filter((r) => r.id !== id));\n            }, RIPPLE_DURATION);\n            rippleTimers.current.add(timer);\n          }\n          onPressIn?.(event);\n        }}\n        onPressOut={(event) => {\n          pressed.value = false;\n          if (animate) scale.value = withSpring(restScale(), motion.press);\n          onPressOut?.(event);\n        }}\n        onHoverIn={(event) => {\n          hovered.value = true;\n          if (animate && !pressed.value) {\n            scale.value = withSpring(HOVER_SCALE, motion.press);\n          }\n          onHoverIn?.(event);\n        }}\n        onHoverOut={(event) => {\n          hovered.value = false;\n          if (animate && !pressed.value) {\n            scale.value = withSpring(1, motion.press);\n          }\n          onHoverOut?.(event);\n        }}\n      >\n        {ripple && (\n          <View\n            className={`absolute inset-0 overflow-hidden ${dimensions.radius}`}\n            style={styles.passThrough}\n            onLayout={({ nativeEvent }) => {\n              layout.current = nativeEvent.layout;\n            }}\n          >\n            {color != null &&\n              ripples.map((item) => (\n                <RippleDot key={item.id} ripple={item} color={color} />\n              ))}\n          </View>\n        )}\n        {children}\n      </AnimatedPressable>\n    </ButtonContext.Provider>\n  );\n}\n\nexport function Button(props: ButtonProps) {\n  if (props.size === \"icon\") {\n    const { icon, ...rest } = props;\n    return (\n      <ButtonRoot {...rest}>\n        <ButtonIcon icon={icon} />\n      </ButtonRoot>\n    );\n  }\n  const { startIcon, endIcon, children, ...rest } = props;\n  const label = Array.isArray(children) ? children.join(\"\") : String(children);\n  return (\n    <ButtonRoot {...rest}>\n      {startIcon != null && <ButtonIcon icon={startIcon} />}\n      <ButtonLabel>{label}</ButtonLabel>\n      {endIcon != null && <ButtonIcon icon={endIcon} />}\n    </ButtonRoot>\n  );\n}\n\nconst styles = StyleSheet.create({\n  icon: { flexShrink: 0 },\n  passThrough: { pointerEvents: \"none\" },\n  ripple: { position: \"absolute\" },\n});\n","type":"shared"},{"path":"src/components/checkbox/checkbox.tsx","content":"import { type Ref, useEffect, useState } from \"react\";\nimport {\n  type ColorValue,\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  View,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\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 { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { EASE_OUT, motion } from \"../../styles/motion\";\n\nconst BOX = 20;\n// The web mark is a 24-unit SVG path drawn at 12px with a 3-unit stroke.\nconst MARK = 12;\nconst CHECK: PathSegment[] = [\n  [5, 13, 9, 17],\n  [9, 17, 19, 7],\n];\nconst DASH: PathSegment[] = [[6, 12, 18, 12]];\n// Tops a 20-point row up to a 48-point touch area.\nconst HIT_SLOP = { top: 14, bottom: 14, left: 0, right: 0 };\nconst COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\n\ntype Mark = \"check\" | \"dash\";\n\nexport type CheckboxProps = Omit<\n  PressableProps,\n  \"children\" | \"style\" | \"onPress\" | \"disabled\"\n> & {\n  /** Controlled state. Omit it and use `defaultChecked` for uncontrolled use. */\n  checked?: boolean;\n  defaultChecked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  /** Shows a dash and reports a mixed state, for \"select all\" rows. */\n  indeterminate?: boolean;\n  disabled?: boolean;\n  label?: string;\n  /** Supporting text under the label, read as the hint rather than the name. */\n  description?: string;\n  /** Disables press and mark motion independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  ref?: Ref<View>;\n};\n\nfunction CheckMark({\n  mark,\n  appear,\n  exiting,\n  reduce,\n  color,\n}: {\n  mark: Mark;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  color: ColorValue | undefined;\n}) {\n  const segments = mark === \"check\" ? CHECK : DASH;\n  const presence = useSharedValue(appear && !reduce ? 0 : 1);\n  const draw = useSharedValue(appear && !reduce ? 0 : 1);\n\n  useEffect(() => {\n    if (reduce) {\n      presence.value = exiting ? 0 : 1;\n      draw.value = 1;\n      return;\n    }\n    presence.value = withTiming(exiting ? 0 : 1, motion.exit);\n    if (!exiting) {\n      draw.value = withDelay(\n        40,\n        withTiming(1, {\n          duration: mark === \"check\" ? 300 : 200,\n          easing: EASE_OUT,\n        }),\n      );\n    }\n  }, [draw, exiting, mark, presence, reduce]);\n\n  const markStyle = useAnimatedStyle(() => ({\n    opacity: presence.value,\n    transform: [{ scale: 0.5 + 0.5 * presence.value }],\n  }));\n\n  return (\n    <Animated.View style={[styles.mark, markStyle]}>\n      <DrawnPath\n        segments={segments}\n        size={MARK}\n        strokeWidth={3}\n        color={color}\n        progress={draw}\n      />\n    </Animated.View>\n  );\n}\n\nexport function Checkbox({\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  indeterminate = false,\n  disabled = false,\n  label,\n  description,\n  disableAnimation = false,\n  className = \"\",\n  accessibilityLabel,\n  accessibilityHint,\n  accessibilityState,\n  hitSlop,\n  onPressIn,\n  onPressOut,\n  ref,\n  ...props\n}: CheckboxProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultChecked);\n  const checked = checkedProp ?? uncontrolled;\n  const showMark = checked || indeterminate;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n\n  const { backgroundColor: idleFill } = useResolveClassNames(\"bg-background\");\n  const { backgroundColor: idleBorder } = useResolveClassNames(\"bg-muted/50\");\n  const { backgroundColor: activeFill } = useResolveClassNames(\"bg-primary\");\n  const { color: markColor } = useResolveClassNames(\"text-on-primary\");\n\n  const active = useSharedValue(showMark ? 1 : 0);\n  const scale = useSharedValue(1);\n  const marks = usePresence<Mark | null>(\n    showMark ? (indeterminate ? \"dash\" : \"check\") : null,\n    showMark ? (indeterminate ? \"dash\" : \"check\") : null,\n    () => motion.exit.duration + 40,\n  );\n\n  useEffect(() => {\n    const target = showMark ? 1 : 0;\n    active.value = animate ? withTiming(target, COLOR_FADE) : target;\n  }, [active, animate, showMark]);\n\n  useEffect(() => {\n    if (!animate || disabled) {\n      cancelAnimation(scale);\n      scale.value = 1;\n    }\n  }, [animate, disabled, scale]);\n\n  const boxStyle = useAnimatedStyle(() => ({\n    backgroundColor: interpolateColor(\n      active.value,\n      [0, 1],\n      [String(idleFill ?? \"transparent\"), String(activeFill ?? \"blue\")],\n    ),\n    borderColor: interpolateColor(\n      active.value,\n      [0, 1],\n      [String(idleBorder ?? \"gray\"), String(activeFill ?? \"blue\")],\n    ),\n    transform: [{ scale: scale.value }],\n  }));\n\n  return (\n    <Pressable\n      {...props}\n      ref={ref}\n      accessible\n      accessibilityRole=\"checkbox\"\n      accessibilityLabel={accessibilityLabel ?? label}\n      accessibilityHint={accessibilityHint ?? description}\n      accessibilityState={{\n        ...accessibilityState,\n        checked: indeterminate ? \"mixed\" : checked,\n        disabled,\n      }}\n      aria-checked={indeterminate ? \"mixed\" : checked}\n      aria-disabled={disabled}\n      disabled={disabled}\n      hitSlop={hitSlop ?? HIT_SLOP}\n      className={`self-start flex-row gap-3 ${description ? \"items-start\" : \"items-center\"} ${className}`}\n      onPress={() => {\n        const next = !checked;\n        if (checkedProp === undefined) setUncontrolled(next);\n        onCheckedChange?.(next);\n      }}\n      onPressIn={(event) => {\n        if (animate) scale.value = withSpring(0.92, motion.press);\n        onPressIn?.(event);\n      }}\n      onPressOut={(event) => {\n        if (animate) scale.value = withSpring(1, motion.press);\n        onPressOut?.(event);\n      }}\n    >\n      <Animated.View\n        style={[styles.box, disabled && styles.disabled, boxStyle]}\n      >\n        {marks.map((item) =>\n          item.value ? (\n            <CheckMark\n              key={item.key}\n              mark={item.value}\n              appear={item.appear}\n              exiting={item.exiting}\n              reduce={!animate}\n              color={markColor}\n            />\n          ) : null,\n        )}\n      </Animated.View>\n      {label || description ? (\n        <View className={`shrink gap-0.5 ${disabled ? \"opacity-60\" : \"\"}`}>\n          {label ? (\n            <Text className=\"text-sm leading-5 text-foreground\">{label}</Text>\n          ) : null}\n          {description ? (\n            <Text className=\"text-xs text-muted\">{description}</Text>\n          ) : null}\n        </View>\n      ) : null}\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  box: {\n    width: BOX,\n    height: BOX,\n    borderRadius: 6,\n    borderWidth: 2,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    flexShrink: 0,\n  },\n  disabled: { opacity: 0.6 },\n  mark: { position: \"absolute\", width: MARK, height: MARK },\n});\n","type":"shared"},{"path":"src/components/input/input.tsx","content":"import { type Ref, useEffect, useRef, useState } from \"react\";\nimport {\n  AccessibilityInfo,\n  Platform,\n  StyleSheet,\n  Text,\n  TextInput,\n  type TextInputProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames, useUniwind } from \"uniwind\";\nimport { usePresence } from \"../../hooks/use-presence\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { useShake } from \"../../hooks/use-shake\";\nimport { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { type IconSource, renderIcon } from \"../../primitives/render-icon\";\nimport { motion } from \"../../styles/motion\";\n\nconst FIELD_HEIGHT = 44;\nconst ICON = 16;\nconst CHECK: PathSegment[] = [\n  [5, 12.5, 9.5, 17],\n  [9.5, 17, 19, 7.5],\n];\nconst SHAKE = [0, -6, 6, -4, 4, -2, 0];\nconst COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\nconst MESSAGE_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  errorMessage?: string;\n};\n\nexport type InputProps = Omit<TextInputProps, \"editable\"> & {\n  label?: string;\n  /** A truthy error shakes the field and turns it red. A string also shows a message. */\n  error?: string | boolean;\n  /** Reserves one message line so validation does not shift content below. */\n  reserveErrorLine?: boolean;\n  /** Draws a check at the trailing edge in place of `rightIcon`. */\n  success?: boolean;\n  /** Decorative icon at the leading edge, given `{ size, color }`. */\n  leftIcon?: IconSource;\n  /** Trailing icon or control, such as a show-password button. */\n  rightIcon?: IconSource;\n  disabled?: boolean;\n  /** Disables shake and transitions independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  classNames?: InputClassNames;\n  ref?: Ref<TextInput>;\n};\n\nfunction ErrorMessage({\n  message,\n  appear,\n  exiting,\n  reduce,\n  className,\n}: {\n  message: string;\n  appear: boolean;\n  exiting: boolean;\n  reduce: boolean;\n  className: string;\n}) {\n  const progress = useSharedValue(appear ? 0 : 1);\n\n  useEffect(() => {\n    progress.value = withTiming(exiting ? 0 : 1, MESSAGE_FADE);\n  }, [exiting, progress]);\n\n  const animatedStyle = useAnimatedStyle(\n    () => ({\n      opacity: progress.value,\n      transform: [{ translateY: reduce ? 0 : -4 * (1 - progress.value) }],\n    }),\n    [reduce],\n  );\n\n  return (\n    <Animated.View\n      style={[exiting && styles.exiting, animatedStyle]}\n      accessibilityLiveRegion=\"polite\"\n    >\n      <Text className={`px-1 text-xs text-destructive ${className}`}>\n        {message}\n      </Text>\n    </Animated.View>\n  );\n}\n\nfunction SuccessCheck({ reduce }: { reduce: boolean }) {\n  const { color } = useResolveClassNames(\"text-success\");\n  const draw = useSharedValue(reduce ? 1 : 0);\n\n  useEffect(() => {\n    if (!reduce) {\n      draw.value = withTiming(1, {\n        duration: 350,\n        easing: Easing.out(Easing.quad),\n      });\n    }\n  }, [draw, reduce]);\n\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      aria-hidden\n      style={styles.trailing}\n    >\n      <DrawnPath\n        segments={CHECK}\n        size={20}\n        strokeWidth={2.5}\n        color={color}\n        progress={draw}\n      />\n    </View>\n  );\n}\n\nexport function Input({\n  label,\n  error,\n  reserveErrorLine = false,\n  success = false,\n  leftIcon,\n  rightIcon,\n  disabled = false,\n  disableAnimation = false,\n  className = \"\",\n  classNames,\n  value: valueProp,\n  defaultValue,\n  onChangeText,\n  onFocus,\n  onBlur,\n  accessibilityLabel,\n  accessibilityHint,\n  keyboardAppearance,\n  style,\n  ref,\n  ...props\n}: InputProps) {\n  // Match the keyboard to the app's theme unless the caller chooses one.\n  const { theme } = useUniwind();\n  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? \"\");\n  const value = valueProp ?? uncontrolled;\n  const [focused, setFocused] = useState(false);\n  const inputRef = useRef<TextInput | null>(null);\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" && error ? error : null;\n  const state = hasError ? \"error\" : focused ? \"focused\" : \"idle\";\n\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const { color: placeholder } = useResolveClassNames(\"text-muted/60\");\n  const { backgroundColor: idleBorder } = useResolveClassNames(\"bg-border\");\n  const { backgroundColor: focusBorder } =\n    useResolveClassNames(\"bg-foreground/40\");\n  const { backgroundColor: focusRing } =\n    useResolveClassNames(\"bg-foreground/10\");\n  const { backgroundColor: errorBorder } =\n    useResolveClassNames(\"bg-destructive\");\n  const { backgroundColor: errorRing } =\n    useResolveClassNames(\"bg-destructive/25\");\n\n  const borderColor =\n    state === \"error\"\n      ? errorBorder\n      : state === \"focused\"\n        ? focusBorder\n        : idleBorder;\n  const ringColor = state === \"error\" ? errorRing : focusRing;\n  const border = useSharedValue(String(borderColor ?? \"transparent\"));\n  const ring = useSharedValue(state === \"idle\" ? 0 : 1);\n  const { shake, style: shakeStyle } = useShake(SHAKE, 450);\n\n  useEffect(() => {\n    const next = String(borderColor ?? \"transparent\");\n    border.value = animate ? withTiming(next, COLOR_FADE) : next;\n    const target = state === \"idle\" ? 0 : 1;\n    ring.value = animate ? withTiming(target, COLOR_FADE) : target;\n  }, [animate, border, borderColor, ring, state]);\n\n  // Shake and announce when an error appears, not while it stays.\n  useEffect(() => {\n    if (!hasError) return;\n    if (animate) shake();\n  }, [animate, hasError, shake]);\n\n  useEffect(() => {\n    if (errorMessage) AccessibilityInfo.announceForAccessibility(errorMessage);\n  }, [errorMessage]);\n\n  const messages = usePresence(\n    errorMessage,\n    errorMessage ?? \"\",\n    () => MESSAGE_FADE.duration + 40,\n  );\n\n  const fieldStyle = useAnimatedStyle(() => ({ borderColor: border.value }));\n  const ringStyle = useAnimatedStyle(() => ({ opacity: ring.value }));\n\n  const trailing = success ? null : rightIcon;\n  const iconProps = { size: ICON, color: muted };\n\n  return (\n    <View className={`gap-1.5 ${className} ${classNames?.root ?? \"\"}`}>\n      {label ? (\n        <Text\n          // The field already carries the label for screen readers.\n          accessibilityElementsHidden\n          importantForAccessibility=\"no\"\n          className={`px-1 text-sm font-medium text-foreground ${classNames?.label ?? \"\"}`}\n          onPress={() => inputRef.current?.focus()}\n        >\n          {label}\n        </Text>\n      ) : null}\n\n      <Animated.View\n        style={[styles.fieldWrap, disabled && styles.disabled, shakeStyle]}\n      >\n        <Animated.View\n          style={[styles.ring, { borderColor: ringColor }, ringStyle]}\n        />\n        <Animated.View\n          className={`flex-row items-center overflow-hidden rounded-full border ${classNames?.field ?? \"\"}`}\n          style={[styles.field, fieldStyle]}\n        >\n          {leftIcon != null ? (\n            <View\n              accessibilityElementsHidden\n              importantForAccessibility=\"no-hide-descendants\"\n              aria-hidden\n              style={styles.leading}\n            >\n              {renderIcon(leftIcon, iconProps)}\n            </View>\n          ) : null}\n          <TextInput\n            {...props}\n            ref={(node) => {\n              inputRef.current = node;\n              if (typeof ref === \"function\") ref(node);\n              else if (ref) ref.current = node;\n            }}\n            value={value}\n            editable={!disabled}\n            accessibilityLabel={accessibilityLabel ?? label}\n            accessibilityHint={accessibilityHint ?? errorMessage ?? undefined}\n            aria-invalid={hasError}\n            aria-disabled={disabled}\n            placeholderTextColor={placeholder}\n            cursorColor={foreground}\n            selectionColor={foreground}\n            className={`h-full flex-1 text-foreground ${leftIcon != null ? \"ps-10\" : \"ps-3.5\"} ${trailing != null || success ? \"pe-10\" : \"pe-3.5\"} ${classNames?.input ?? \"\"}`}\n            style={[styles.input, style]}\n            keyboardAppearance={keyboardAppearance ?? theme}\n            onChangeText={(next) => {\n              if (valueProp === undefined) setUncontrolled(next);\n              onChangeText?.(next);\n            }}\n            onFocus={(event) => {\n              setFocused(true);\n              onFocus?.(event);\n            }}\n            onBlur={(event) => {\n              setFocused(false);\n              onBlur?.(event);\n            }}\n          />\n          {success ? (\n            <SuccessCheck reduce={!animate} />\n          ) : trailing != null ? (\n            <View style={styles.trailingControl}>\n              {renderIcon(trailing, iconProps)}\n            </View>\n          ) : null}\n        </Animated.View>\n      </Animated.View>\n\n      <View style={reserveErrorLine && styles.reserved}>\n        {messages.map((item) =>\n          item.value ? (\n            <ErrorMessage\n              key={item.key}\n              message={item.value}\n              appear={item.appear}\n              exiting={item.exiting}\n              reduce={!animate}\n              className={classNames?.errorMessage ?? \"\"}\n            />\n          ) : null,\n        )}\n      </View>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fieldWrap: { height: FIELD_HEIGHT },\n  disabled: { opacity: 0.6 },\n  // A 2-point ring around the border box, like the web's ring-2.\n  ring: {\n    position: \"absolute\",\n    top: -2,\n    bottom: -2,\n    left: -2,\n    right: -2,\n    borderRadius: FIELD_HEIGHT,\n    borderWidth: 2,\n    pointerEvents: \"none\",\n  },\n  field: { height: FIELD_HEIGHT },\n  input: {\n    // A font size without a line height: a TextInput line height pushes the\n    // text and placeholder off center on iOS.\n    fontSize: 16,\n    paddingVertical: 0,\n    // Android centers text vertically only when asked, and adds font padding.\n    textAlignVertical: \"center\",\n    includeFontPadding: false,\n    // Web inputs draw a focus outline; the field's border and ring replace it.\n    // Chrome draws an \"auto\" outline whatever its width, so the style has to\n    // go. React Native's types don't list \"none\", but React Native Web passes\n    // it through.\n    ...Platform.select({\n      web: { outlineStyle: \"none\" as \"solid\" },\n      default: {},\n    }),\n  },\n  leading: {\n    position: \"absolute\",\n    start: 12,\n    pointerEvents: \"none\",\n  },\n  trailing: {\n    position: \"absolute\",\n    end: 14,\n    pointerEvents: \"none\",\n  },\n  // A 44-point square keeps trailing controls such as a password toggle tappable.\n  trailingControl: {\n    position: \"absolute\",\n    end: 0,\n    top: 0,\n    bottom: 0,\n    width: 44,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  reserved: { minHeight: 16 },\n  exiting: { position: \"absolute\", left: 0, right: 0, top: 0 },\n});\n","type":"shared"},{"path":"src/components/radio/radio.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { motion } from \"../../styles/motion\";\n\nconst RING = 20;\nconst BORDER = 2;\nconst DOT = 8;\nconst DOT_INSET = (RING - DOT) / 2;\n// Tops a 20-point row up to a 48-point touch area.\nconst HIT_SLOP = { top: 14, bottom: 14, left: 0, right: 0 };\nconst COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\n\ntype Measure = () => void;\n\ntype RadioGroupContextValue = {\n  value: string;\n  select: (value: string) => void;\n  animate: boolean;\n  groupRef: RefObject<View | null>;\n  /** Records where an item's ring sits inside the group. */\n  place: (value: string, x: number, y: number) => void;\n  register: (value: string, measure: Measure) => () => void;\n  dotScale: SharedValue<number>;\n};\n\nconst RadioGroupContext = createContext<RadioGroupContextValue | null>(null);\n\nfunction useRadioGroup() {\n  const context = useContext(RadioGroupContext);\n  if (!context) {\n    throw new Error(\"RadioGroupItem must be rendered inside a RadioGroup.\");\n  }\n  return context;\n}\n\nexport type RadioGroupProps = Omit<ViewProps, \"children\"> & {\n  /** Controlled value. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Disables dot and press motion independently of the system preference. */\n  disableAnimation?: boolean;\n  children: ReactNode;\n  className?: string;\n};\n\nexport function RadioGroup({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  orientation = \"vertical\",\n  disableAnimation = false,\n  children,\n  className = \"\",\n  onLayout,\n  ...props\n}: RadioGroupProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const value = valueProp ?? uncontrolled;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const { backgroundColor: dotColor } = useResolveClassNames(\"bg-primary\");\n\n  const groupRef = useRef<View>(null);\n  const positions = useRef(new Map<string, { x: number; y: number }>());\n  const measures = useRef(new Map<string, Measure>());\n  const valueRef = useRef(value);\n  valueRef.current = value;\n  const animateRef = useRef(animate);\n  animateRef.current = animate;\n\n  const x = useSharedValue(0);\n  const y = useSharedValue(0);\n  const visible = useSharedValue(0);\n  const dotScale = useSharedValue(1);\n\n  // Glide to the selected ring, or appear in place when nothing was shown yet.\n  const moveDot = useCallback(\n    (target: string) => {\n      const position = positions.current.get(target);\n      if (!position) {\n        visible.value = 0;\n        return;\n      }\n      const left = position.x + DOT_INSET;\n      const top = position.y + DOT_INSET;\n      if (visible.value === 0 || !animateRef.current) {\n        x.value = left;\n        y.value = top;\n      } else {\n        x.value = withSpring(left, motion.layout);\n        y.value = withSpring(top, motion.layout);\n      }\n      visible.value = 1;\n    },\n    [visible, x, y],\n  );\n\n  useEffect(() => {\n    moveDot(value);\n  }, [moveDot, value]);\n\n  // Stable so items only re-register when their own value changes.\n  const place = useCallback(\n    (item: string, left: number, top: number) => {\n      positions.current.set(item, { x: left, y: top });\n      if (item === valueRef.current) moveDot(item);\n    },\n    [moveDot],\n  );\n\n  const register = useCallback(\n    (item: string, measure: Measure) => {\n      measures.current.set(item, measure);\n      return () => {\n        measures.current.delete(item);\n        positions.current.delete(item);\n        if (item === valueRef.current) visible.value = 0;\n      };\n    },\n    [visible],\n  );\n\n  const context = useMemo<RadioGroupContextValue>(\n    () => ({\n      value,\n      animate,\n      groupRef,\n      dotScale,\n      place,\n      register,\n      select: (next) => {\n        if (valueProp === undefined) setUncontrolled(next);\n        onValueChange?.(next);\n      },\n    }),\n    [animate, dotScale, onValueChange, place, register, value, valueProp],\n  );\n\n  const dotStyle = useAnimatedStyle(() => ({\n    opacity: visible.value,\n    transform: [\n      { translateX: x.value },\n      { translateY: y.value },\n      { scale: dotScale.value },\n    ],\n  }));\n\n  return (\n    <RadioGroupContext.Provider value={context}>\n      <View\n        {...props}\n        ref={groupRef}\n        accessibilityRole=\"radiogroup\"\n        className={`relative gap-3 ${orientation === \"vertical\" ? \"flex-col\" : \"flex-row flex-wrap\"} ${className}`}\n        onLayout={(event) => {\n          // Items can move without their own layout changing, such as on reflow.\n          for (const measure of measures.current.values()) measure();\n          onLayout?.(event);\n        }}\n      >\n        {children}\n        <Animated.View\n          style={[styles.dot, { backgroundColor: dotColor }, dotStyle]}\n        />\n      </View>\n    </RadioGroupContext.Provider>\n  );\n}\n\nexport type RadioGroupItemProps = Omit<\n  PressableProps,\n  \"children\" | \"style\" | \"onPress\" | \"disabled\"\n> & {\n  value: string;\n  label?: string;\n  /** Supporting text under the label, read as the hint rather than the name. */\n  description?: string;\n  disabled?: boolean;\n  className?: string;\n  ref?: Ref<View>;\n};\n\nexport function RadioGroupItem({\n  value,\n  label,\n  description,\n  disabled = false,\n  className = \"\",\n  accessibilityLabel,\n  accessibilityHint,\n  accessibilityState,\n  hitSlop,\n  onPressIn,\n  onPressOut,\n  ref,\n  ...props\n}: RadioGroupItemProps) {\n  const group = useRadioGroup();\n  const { animate, dotScale, groupRef, place, register } = group;\n  const selected = group.value === value;\n  const ringRef = useRef<View>(null);\n\n  const { backgroundColor: idleBorder } = useResolveClassNames(\"bg-muted/50\");\n  const { backgroundColor: activeBorder } = useResolveClassNames(\"bg-primary\");\n\n  const active = useSharedValue(selected ? 1 : 0);\n  const scale = useSharedValue(1);\n\n  const measure = useCallback(() => {\n    const ring = ringRef.current;\n    const groupNode = groupRef.current;\n    if (!ring || !groupNode) return;\n    ring.measureLayout(groupNode, (left, top) => place(value, left, top));\n  }, [groupRef, place, value]);\n\n  useEffect(() => register(value, measure), [measure, register, value]);\n\n  useEffect(() => {\n    const target = selected ? 1 : 0;\n    active.value = animate ? withTiming(target, COLOR_FADE) : target;\n  }, [active, animate, selected]);\n\n  useEffect(() => {\n    if (!animate || disabled) {\n      cancelAnimation(scale);\n      scale.value = 1;\n    }\n  }, [animate, disabled, scale]);\n\n  const ringStyle = useAnimatedStyle(() => ({\n    borderColor: interpolateColor(\n      active.value,\n      [0, 1],\n      [String(idleBorder ?? \"gray\"), String(activeBorder ?? \"blue\")],\n    ),\n    transform: [{ scale: scale.value }],\n  }));\n\n  return (\n    <Pressable\n      {...props}\n      ref={ref}\n      accessible\n      accessibilityRole=\"radio\"\n      accessibilityLabel={accessibilityLabel ?? label}\n      accessibilityHint={accessibilityHint ?? description}\n      accessibilityState={{\n        ...accessibilityState,\n        checked: selected,\n        disabled,\n      }}\n      aria-checked={selected}\n      aria-disabled={disabled}\n      disabled={disabled}\n      hitSlop={hitSlop ?? HIT_SLOP}\n      className={`self-start flex-row gap-3 ${description ? \"items-start\" : \"items-center\"} ${className}`}\n      onPress={() => {\n        if (!selected) group.select(value);\n      }}\n      onPressIn={(event) => {\n        if (animate) {\n          scale.value = withSpring(0.92, motion.press);\n          // The dot lives in the group, so it presses along with its ring.\n          if (selected) dotScale.value = withSpring(0.92, motion.press);\n        }\n        onPressIn?.(event);\n      }}\n      onPressOut={(event) => {\n        if (animate) {\n          scale.value = withSpring(1, motion.press);\n          dotScale.value = withSpring(1, motion.press);\n        }\n        onPressOut?.(event);\n      }}\n    >\n      <Animated.View\n        ref={ringRef}\n        onLayout={measure}\n        style={[styles.ring, disabled && styles.disabled, ringStyle]}\n      />\n      {label || description ? (\n        <View className={`shrink gap-0.5 ${disabled ? \"opacity-60\" : \"\"}`}>\n          {label ? (\n            <Text className=\"text-sm leading-5 text-foreground\">{label}</Text>\n          ) : null}\n          {description ? (\n            <Text className=\"text-xs text-muted\">{description}</Text>\n          ) : null}\n        </View>\n      ) : null}\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  ring: {\n    width: RING,\n    height: RING,\n    borderRadius: RING / 2,\n    borderWidth: BORDER,\n    flexShrink: 0,\n  },\n  disabled: { opacity: 0.6 },\n  dot: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: DOT,\n    height: DOT,\n    borderRadius: DOT / 2,\n    pointerEvents: \"none\",\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/hooks/use-shake.ts","content":"import { useCallback } from \"react\";\nimport {\n  type EasingFunction,\n  type EasingFunctionFactory,\n  useAnimatedStyle,\n  useSharedValue,\n  withSequence,\n  withTiming,\n} from \"react-native-reanimated\";\n\n/**\n * Horizontal shake through `keyframes` (in points) over `duration` ms, split\n * evenly like a Motion keyframe array. Callers skip it under reduced motion.\n */\nexport function useShake(\n  keyframes: number[],\n  duration: number,\n  easing?: EasingFunction | EasingFunctionFactory,\n) {\n  const offset = useSharedValue(0);\n  const step = duration / Math.max(1, keyframes.length - 1);\n\n  const shake = useCallback(() => {\n    const [first = 0, ...rest] = keyframes;\n    offset.value = first;\n    offset.value = withSequence(\n      // An explicit `easing: undefined` would replace Reanimated's default.\n      ...rest.map((point) =>\n        withTiming(\n          point,\n          easing ? { duration: step, easing } : { duration: step },\n        ),\n      ),\n    );\n  }, [easing, keyframes, offset, step]);\n\n  const style = useAnimatedStyle(() => ({\n    transform: [{ translateX: offset.value }],\n  }));\n\n  return { shake, style };\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/disclosure.tsx","content":"import { type ReactNode, useEffect, useRef } from \"react\";\nimport { StyleSheet, View, type ViewStyle } from \"react-native\";\nimport Animated, {\n  measure,\n  useAnimatedRef,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnUI } from \"react-native-worklets\";\nimport { EASE_OUT } from \"../styles/motion\";\n\nconst OPEN = { duration: 220, easing: EASE_OUT };\nconst CLOSE = { duration: 140, easing: EASE_OUT };\nconst DROP = 4;\n// The height while settled open: unconstrained, so growing content just flows.\nconst NATURAL = -1;\n\nexport type DisclosureProps = {\n  open: boolean;\n  /** Snaps open and closed, keeping only the fade. */\n  reduce: boolean;\n  /** Caps the open height; content clips beyond it. */\n  maxHeight?: number;\n  style?: ViewStyle;\n  children: ReactNode;\n};\n\n/**\n * Collapsible content shared by the agent components: the height eases\n * between closed and the content's size while the content fades and drops 4\n * points into place. Settled open, the height is left alone, so content that\n * grows (streamed rows, new sources) is never clipped. Closed content is\n * hidden from screen readers and touches.\n */\nexport function Disclosure({\n  open,\n  reduce,\n  maxHeight,\n  style,\n  children,\n}: DisclosureProps) {\n  // The content's height, kept current by layout events; measuring on demand\n  // can read 0 for content inside a scroll view.\n  const contentHeight = useSharedValue(0);\n  const clipRef = useAnimatedRef<Animated.View>();\n  const height = useSharedValue(open ? NATURAL : 0);\n  const shown = useSharedValue(open ? 1 : 0);\n  const mounted = useRef(false);\n\n  useEffect(() => {\n    // The first render is already in place.\n    if (!mounted.current) {\n      mounted.current = true;\n      return;\n    }\n    const cap = maxHeight ?? Number.POSITIVE_INFINITY;\n    scheduleOnUI(() => {\n      \"worklet\";\n      // Real sizes, read on the UI thread at the moment of the change.\n      const full = Math.min(contentHeight.value, cap);\n      if (open) {\n        const from = height.value === NATURAL ? full : height.value;\n        if (reduce) {\n          height.value = NATURAL;\n        } else {\n          height.value = from;\n          height.value = withTiming(full, OPEN, (done) => {\n            if (done) height.value = NATURAL;\n          });\n        }\n      } else {\n        const clip = measure(clipRef);\n        const from =\n          height.value === NATURAL ? (clip?.height ?? full) : height.value;\n        height.value = from;\n        height.value = reduce ? 0 : withTiming(0, CLOSE);\n      }\n    });\n    shown.value = withTiming(\n      open ? 1 : 0,\n      reduce ? { duration: 0 } : open ? OPEN : CLOSE,\n    );\n  }, [clipRef, contentHeight, height, maxHeight, open, reduce, shown]);\n\n  const clipStyle = useAnimatedStyle(() =>\n    // Settled open, the clip follows the content's measured height.\n    height.value === NATURAL\n      ? {\n          height: Math.min(\n            contentHeight.value,\n            maxHeight ?? Number.POSITIVE_INFINITY,\n          ),\n        }\n      : { height: height.value },\n  );\n  const contentStyle = useAnimatedStyle(\n    () => ({\n      opacity: shown.value,\n      transform: [{ translateY: reduce ? 0 : (1 - shown.value) * -DROP }],\n    }),\n    [reduce],\n  );\n\n  return (\n    <Animated.View\n      ref={clipRef}\n      pointerEvents={open ? \"auto\" : \"none\"}\n      accessibilityElementsHidden={!open}\n      importantForAccessibility={open ? \"auto\" : \"no-hide-descendants\"}\n      style={[styles.clip, clipStyle, style]}\n    >\n      {/* Absolutely placed, so the content keeps its natural height while the\n          clip around it is short, and can be measured at any time. */}\n      <Animated.View style={[styles.content, contentStyle]}>\n        <View\n          style={styles.measure}\n          onLayout={({ nativeEvent }) => {\n            contentHeight.value = nativeEvent.layout.height;\n          }}\n        >\n          {children}\n        </View>\n      </Animated.View>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  clip: { overflow: \"hidden\" },\n  content: { position: \"absolute\", left: 0, right: 0, top: 0 },\n  measure: { flexShrink: 0 },\n});\n","type":"shared"},{"path":"src/primitives/drawn-path.tsx","content":"import { type ColorValue, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  interpolate,\n  type SharedValue,\n  useAnimatedStyle,\n} from \"react-native-reanimated\";\n\n/** A straight line in a square viewBox: [x1, y1, x2, y2]. */\nexport type PathSegment = [x1: number, y1: number, x2: number, y2: number];\n\nexport type DrawnPathProps = {\n  /** Straight segments drawn in order, like an SVG polyline. */\n  segments: PathSegment[];\n  /** Rendered width and height in points. */\n  size: number;\n  /** Stroke width in viewBox units. */\n  strokeWidth: number;\n  /** Side of the square viewBox the segments are written in. Defaults to 24. */\n  viewBox?: number;\n  color: ColorValue | undefined;\n  /** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */\n  progress: SharedValue<number>;\n};\n\nfunction length([x1, y1, x2, y2]: PathSegment) {\n  return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/**\n * Draws a polyline on with rotated Views, so shared components need no SVG\n * library. Round caps extend half a stroke past each end, like SVG's linecap.\n */\nexport function DrawnPath({\n  segments,\n  size,\n  strokeWidth,\n  viewBox = 24,\n  color,\n  progress,\n}: DrawnPathProps) {\n  const unit = size / viewBox;\n  const stroke = strokeWidth * unit;\n  const total = segments.reduce((sum, segment) => sum + length(segment), 0);\n  let start = 0;\n\n  return (\n    <View style={{ width: size, height: size }}>\n      {segments.map((segment) => {\n        const from = start / total;\n        start += length(segment);\n        return (\n          <Stroke\n            key={segment.join()}\n            segment={segment}\n            unit={unit}\n            stroke={stroke}\n            from={from}\n            to={start / total}\n            progress={progress}\n            color={color}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nfunction Stroke({\n  segment,\n  unit,\n  stroke,\n  from,\n  to,\n  progress,\n  color,\n}: {\n  segment: PathSegment;\n  unit: number;\n  stroke: number;\n  from: number;\n  to: number;\n  progress: SharedValue<number>;\n  color: ColorValue | undefined;\n}) {\n  const [x1, y1, x2, y2] = segment;\n  const full = length(segment) * unit;\n  const angle = Math.atan2(y2 - y1, x2 - x1);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const drawn = interpolate(progress.value, [from, to], [0, 1], \"clamp\");\n    return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };\n  });\n\n  return (\n    <Animated.View\n      style={[\n        styles.stroke,\n        {\n          left: x1 * unit - stroke / 2,\n          top: y1 * unit - stroke / 2,\n          height: stroke,\n          borderRadius: stroke / 2,\n          backgroundColor: color,\n          // Rotate around the start point so the stroke grows from it.\n          transformOrigin: [stroke / 2, stroke / 2, 0],\n          transform: [{ rotate: `${angle}rad` }],\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  stroke: { position: \"absolute\" },\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/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"}]}