@expokit/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/bin/expo-kit.js +1204 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +338 -0
  6. package/package.json +44 -4
  7. package/registry/_docs/accordion.json +99 -0
  8. package/registry/_docs/action-sheet.json +110 -0
  9. package/registry/_docs/alert.json +127 -0
  10. package/registry/_docs/avatar.json +203 -0
  11. package/registry/_docs/badge.json +117 -0
  12. package/registry/_docs/bottom-nav.json +119 -0
  13. package/registry/_docs/bottom-sheet.json +158 -0
  14. package/registry/_docs/bullet.json +24 -0
  15. package/registry/_docs/button-3d.json +122 -0
  16. package/registry/_docs/button.json +115 -0
  17. package/registry/_docs/calendar.json +182 -0
  18. package/registry/_docs/card-stack.json +108 -0
  19. package/registry/_docs/card.json +111 -0
  20. package/registry/_docs/carousel.json +102 -0
  21. package/registry/_docs/checkbox.json +93 -0
  22. package/registry/_docs/checkout.json +102 -0
  23. package/registry/_docs/chip-group.json +116 -0
  24. package/registry/_docs/color-swatch.json +66 -0
  25. package/registry/_docs/confetti.json +82 -0
  26. package/registry/_docs/coupon.json +176 -0
  27. package/registry/_docs/cube-pager.json +84 -0
  28. package/registry/_docs/dashboard.json +120 -0
  29. package/registry/_docs/date-picker.json +249 -0
  30. package/registry/_docs/dialog.json +162 -0
  31. package/registry/_docs/drawer.json +171 -0
  32. package/registry/_docs/fab.json +120 -0
  33. package/registry/_docs/flip-card.json +111 -0
  34. package/registry/_docs/floating-input.json +191 -0
  35. package/registry/_docs/gift-card.json +108 -0
  36. package/registry/_docs/glass-button.json +118 -0
  37. package/registry/_docs/glass-nav-bar.json +78 -0
  38. package/registry/_docs/glass-search-bar.json +90 -0
  39. package/registry/_docs/glass-segmented.json +66 -0
  40. package/registry/_docs/glass-sheet.json +108 -0
  41. package/registry/_docs/glass-tab-bar.json +76 -0
  42. package/registry/_docs/glass.json +78 -0
  43. package/registry/_docs/inbox.json +72 -0
  44. package/registry/_docs/isometric-bar.json +72 -0
  45. package/registry/_docs/kit.json +11 -0
  46. package/registry/_docs/kpi-card.json +11 -0
  47. package/registry/_docs/like-button.json +72 -0
  48. package/registry/_docs/liquidglass-tab.json +27 -0
  49. package/registry/_docs/list-item.json +200 -0
  50. package/registry/_docs/loyalty-card.json +108 -0
  51. package/registry/_docs/morph-button.json +108 -0
  52. package/registry/_docs/number-ticker.json +96 -0
  53. package/registry/_docs/offers.json +78 -0
  54. package/registry/_docs/onboarding.json +66 -0
  55. package/registry/_docs/otp-input.json +134 -0
  56. package/registry/_docs/pagination-dots.json +88 -0
  57. package/registry/_docs/parallax-layers.json +84 -0
  58. package/registry/_docs/parallax-scroll.json +186 -0
  59. package/registry/_docs/password-input.json +92 -0
  60. package/registry/_docs/payment-card.json +153 -0
  61. package/registry/_docs/pixel-bar.json +66 -0
  62. package/registry/_docs/polar-area.json +78 -0
  63. package/registry/_docs/profile.json +84 -0
  64. package/registry/_docs/progress-steps.json +92 -0
  65. package/registry/_docs/progress.json +132 -0
  66. package/registry/_docs/radio-group.json +92 -0
  67. package/registry/_docs/rating.json +90 -0
  68. package/registry/_docs/ring-carousel.json +102 -0
  69. package/registry/_docs/scratch-card.json +90 -0
  70. package/registry/_docs/search-bar.json +140 -0
  71. package/registry/_docs/search-results.json +120 -0
  72. package/registry/_docs/segmented-control.json +103 -0
  73. package/registry/_docs/select.json +252 -0
  74. package/registry/_docs/settings.json +72 -0
  75. package/registry/_docs/shimmer-button.json +78 -0
  76. package/registry/_docs/sign-in.json +198 -0
  77. package/registry/_docs/skeleton.json +142 -0
  78. package/registry/_docs/slide-to-confirm.json +84 -0
  79. package/registry/_docs/slider.json +157 -0
  80. package/registry/_docs/spin-wheel.json +114 -0
  81. package/registry/_docs/spinner.json +91 -0
  82. package/registry/_docs/stepper.json +168 -0
  83. package/registry/_docs/swipe-deck.json +102 -0
  84. package/registry/_docs/swipeable-row.json +72 -0
  85. package/registry/_docs/switch.json +104 -0
  86. package/registry/_docs/tabs.json +105 -0
  87. package/registry/_docs/tag-input.json +128 -0
  88. package/registry/_docs/textarea.json +175 -0
  89. package/registry/_docs/ticket-card.json +174 -0
  90. package/registry/_docs/ticket-shape.json +11 -0
  91. package/registry/_docs/tilt-card.json +141 -0
  92. package/registry/_docs/time-picker.json +253 -0
  93. package/registry/_docs/timeline-chart.json +30 -0
  94. package/registry/_docs/timeline.json +156 -0
  95. package/registry/_docs/toast.json +161 -0
  96. package/registry/_docs/treemap.json +30 -0
  97. package/registry/_docs/waterfall.json +36 -0
  98. package/registry/_docs/wheel-list.json +102 -0
  99. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  100. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  101. package/registry/accordion.json +17 -0
  102. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  103. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  104. package/registry/action-sheet.json +18 -0
  105. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  106. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  107. package/registry/alert/files/src/lib/kit.tsx +219 -0
  108. package/registry/alert.json +20 -0
  109. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  110. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  111. package/registry/avatar.json +18 -0
  112. package/registry/badge/files/src/components/ui/badge.tsx +316 -0
  113. package/registry/badge/files/src/lib/kit.tsx +219 -0
  114. package/registry/badge.json +17 -0
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  117. package/registry/bottom-nav.json +18 -0
  118. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  119. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-sheet.json +17 -0
  121. package/registry/bullet.json +14 -0
  122. package/registry/button/files/src/components/ui/button.tsx +274 -0
  123. package/registry/button/files/src/lib/kit.tsx +219 -0
  124. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  125. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  126. package/registry/button-3d.json +18 -0
  127. package/registry/button.json +18 -0
  128. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  129. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  130. package/registry/calendar.json +17 -0
  131. package/registry/card/files/src/components/ui/card.tsx +300 -0
  132. package/registry/card/files/src/lib/kit.tsx +219 -0
  133. package/registry/card-stack.json +15 -0
  134. package/registry/card.json +17 -0
  135. package/registry/carousel.json +15 -0
  136. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  137. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  138. package/registry/checkbox.json +17 -0
  139. package/registry/checkout.json +26 -0
  140. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  141. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  142. package/registry/chip-group.json +17 -0
  143. package/registry/color-swatch.json +17 -0
  144. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  145. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  146. package/registry/confetti.json +15 -0
  147. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  148. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  149. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  150. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  151. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  152. package/registry/coupon.json +23 -0
  153. package/registry/cube-pager.json +15 -0
  154. package/registry/dashboard.json +32 -0
  155. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  156. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  157. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  158. package/registry/date-picker.json +19 -0
  159. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  160. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  161. package/registry/dialog.json +18 -0
  162. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  163. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  164. package/registry/drawer.json +18 -0
  165. package/registry/fab.json +18 -0
  166. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  167. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  168. package/registry/flip-card.json +15 -0
  169. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  170. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  171. package/registry/floating-input.json +17 -0
  172. package/registry/gift-card.json +21 -0
  173. package/registry/glass-button.json +22 -0
  174. package/registry/glass-nav-bar.json +21 -0
  175. package/registry/glass-search-bar.json +21 -0
  176. package/registry/glass-segmented.json +22 -0
  177. package/registry/glass-sheet.json +22 -0
  178. package/registry/glass-tab-bar.json +22 -0
  179. package/registry/glass.json +19 -0
  180. package/registry/inbox.json +30 -0
  181. package/registry/isometric-bar.json +14 -0
  182. package/registry/kit/files/src/lib/kit.tsx +219 -0
  183. package/registry/kit.json +11 -0
  184. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  185. package/registry/kpi-card.json +14 -0
  186. package/registry/like-button.json +18 -0
  187. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  188. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  189. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  190. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  192. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  193. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  194. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  195. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  196. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  197. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  198. package/registry/liquidglass-tab.json +23 -0
  199. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  200. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  201. package/registry/list-item.json +17 -0
  202. package/registry/loyalty-card.json +23 -0
  203. package/registry/morph-button.json +18 -0
  204. package/registry/number-ticker.json +15 -0
  205. package/registry/offers.json +24 -0
  206. package/registry/onboarding.json +17 -0
  207. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  208. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  209. package/registry/otp-input.json +17 -0
  210. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  211. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  212. package/registry/pagination-dots.json +15 -0
  213. package/registry/parallax-layers.json +15 -0
  214. package/registry/parallax-scroll.json +17 -0
  215. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  216. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  217. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  218. package/registry/password-input.json +19 -0
  219. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  220. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  221. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  222. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  223. package/registry/payment-card.json +21 -0
  224. package/registry/pixel-bar.json +14 -0
  225. package/registry/polar-area.json +14 -0
  226. package/registry/profile.json +17 -0
  227. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  228. package/registry/progress/files/src/lib/kit.tsx +219 -0
  229. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  230. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  231. package/registry/progress-steps.json +18 -0
  232. package/registry/progress.json +17 -0
  233. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  234. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  235. package/registry/radio-group.json +18 -0
  236. package/registry/rating.json +18 -0
  237. package/registry/ring-carousel.json +15 -0
  238. package/registry/scratch-card.json +18 -0
  239. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  240. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  241. package/registry/search-bar.json +17 -0
  242. package/registry/search-results.json +30 -0
  243. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  244. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  245. package/registry/segmented-control.json +18 -0
  246. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  247. package/registry/select/files/src/lib/kit.tsx +219 -0
  248. package/registry/select.json +17 -0
  249. package/registry/settings.json +26 -0
  250. package/registry/shimmer-button.json +17 -0
  251. package/registry/sign-in.json +26 -0
  252. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  253. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  254. package/registry/skeleton.json +17 -0
  255. package/registry/slide-to-confirm.json +18 -0
  256. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  257. package/registry/slider/files/src/lib/kit.tsx +219 -0
  258. package/registry/slider.json +17 -0
  259. package/registry/spin-wheel.json +18 -0
  260. package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
  261. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  262. package/registry/spinner.json +17 -0
  263. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  264. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  265. package/registry/stepper.json +17 -0
  266. package/registry/swipe-deck.json +15 -0
  267. package/registry/swipeable-row.json +15 -0
  268. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  269. package/registry/switch/files/src/lib/kit.tsx +219 -0
  270. package/registry/switch.json +18 -0
  271. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  272. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  273. package/registry/tabs.json +17 -0
  274. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  275. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  276. package/registry/tag-input.json +17 -0
  277. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  278. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  279. package/registry/textarea.json +17 -0
  280. package/registry/ticket-card.json +24 -0
  281. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  282. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  283. package/registry/ticket-shape.json +17 -0
  284. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  285. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  286. package/registry/tilt-card.json +17 -0
  287. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  288. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  289. package/registry/time-picker.json +17 -0
  290. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  291. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  292. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  293. package/registry/timeline-chart.json +14 -0
  294. package/registry/timeline.json +20 -0
  295. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  296. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  297. package/registry/toast/files/src/lib/kit.tsx +219 -0
  298. package/registry/toast.json +20 -0
  299. package/registry/treemap.json +14 -0
  300. package/registry/waterfall.json +14 -0
  301. package/registry/wheel-list.json +17 -0
  302. package/schema/components.schema.json +13 -0
@@ -0,0 +1,1135 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ BackHandler,
7
+ Easing,
8
+ Image,
9
+ Modal,
10
+ PanResponder,
11
+ Platform,
12
+ Pressable,
13
+ ScrollView,
14
+ StatusBar,
15
+ StyleSheet,
16
+ Text,
17
+ View,
18
+ useWindowDimensions,
19
+ type LayoutChangeEvent,
20
+ type PanResponderGestureState,
21
+ type StyleProp,
22
+ type ViewStyle,
23
+ } from 'react-native';
24
+ import Svg, { Path } from 'react-native-svg';
25
+
26
+ import { alpha, motion, useKit } from '@/lib/kit';
27
+
28
+ export type DrawerSide = 'left' | 'right';
29
+ /**
30
+ * `overlay` — the panel slides over the screen, which dims (and gently scales with `scaleContent`).
31
+ * `push` — the screen becomes a rounded card that shrinks to 0.92 and slides aside, revealing the
32
+ * panel underneath (the modern mail-app look). Needs `children`; without them it renders as overlay.
33
+ */
34
+ export type DrawerVariant = 'overlay' | 'push';
35
+
36
+ const INLINE_TOP = Platform.select({ ios: 14, android: 10, default: 8 }) ?? 8;
37
+ const INLINE_BOTTOM = Platform.select({ ios: 18, android: 12, default: 10 }) ?? 10;
38
+ const MODAL_TOP = Platform.select({ ios: 52, android: (StatusBar.currentHeight ?? 24) + 8, default: 16 }) ?? 16;
39
+ const MODAL_BOTTOM = Platform.select({ ios: 28, android: 16, default: 12 }) ?? 12;
40
+
41
+ /** Panel surface hidden past the screen edge, so overshoot and rubber-banding never show a gap. */
42
+ const EXTRA = 48;
43
+ /** Extra travel when closed so the panel's shadow is off-screen too. */
44
+ const SHADOW_PAD = 28;
45
+ const PUSH_SCALE = 0.92;
46
+ const PUSH_GAP = 14;
47
+ /** Kit snappy with a touch more damping: the panel lands with a hint of overshoot, not a wobble. */
48
+ const OPEN_SPRING = { ...motion.snappy, damping: 22 };
49
+ /** Closing is clamped, so it ends on the first crossing — exits feel faster than entrances. */
50
+ const CLOSE_SPRING = { ...motion.snappy, overshootClamping: true };
51
+
52
+ interface DrawerContextValue {
53
+ open: boolean;
54
+ setOpen: (open: boolean) => void;
55
+ toggle: () => void;
56
+ close: () => void;
57
+ }
58
+
59
+ const DrawerContext = createContext<DrawerContextValue | null>(null);
60
+ /** The colour behind the panel content, so status-dot rings blend in. */
61
+ const SurfaceContext = createContext<string | null>(null);
62
+
63
+ /** Open / close the nearest Drawer from anywhere inside it (header buttons, DrawerItems…). */
64
+ export function useDrawer(): DrawerContextValue {
65
+ const ctx = useContext(DrawerContext);
66
+ return ctx ?? { open: false, setOpen: () => {}, toggle: () => {}, close: () => {} };
67
+ }
68
+
69
+ export interface DrawerProps {
70
+ open?: boolean;
71
+ defaultOpen?: boolean;
72
+ onOpenChange?: (open: boolean) => void;
73
+ /** The panel's content (usually DrawerItems). Scrolls between `header` and `footer`. */
74
+ content: React.ReactNode;
75
+ /** Pinned above the scrolling content (DrawerProfile or DrawerHeader). */
76
+ header?: React.ReactNode;
77
+ /** Pinned below the scrolling content (DrawerFooter, a DrawerItem or buttons). */
78
+ footer?: React.ReactNode;
79
+ /**
80
+ * The screen the drawer belongs to. When given, the drawer lives inside this container (no
81
+ * Modal): edge swipe and the 'push' variant work. Without children it opens in a Modal.
82
+ */
83
+ children?: React.ReactNode;
84
+ side?: DrawerSide;
85
+ /** Points or a percentage of the container width (default '80%', max 360). */
86
+ width?: number | `${number}%`;
87
+ /** 'overlay' slides over the screen · 'push' shrinks the screen into a card and slides it aside. */
88
+ variant?: DrawerVariant;
89
+ /** Swipe in from the screen edge to open (needs `children`, default true). */
90
+ edgeSwipe?: boolean;
91
+ /** Width of the edge-swipe zone (default 24). Taps in it still reach your screen. */
92
+ edgeWidth?: number;
93
+ /** Close on scrim tap and drag (default true). */
94
+ dismissible?: boolean;
95
+ /** Overlay variant: scale the screen behind to 0.96 with rounded corners while open (inline only, default true). */
96
+ scaleContent?: boolean;
97
+ /** Light haptic when the drawer opens; DrawerItems tick on press (default true). */
98
+ haptics?: boolean;
99
+ /** Space above the header (default: a small inline gap, or the status bar height in Modal mode). */
100
+ topInset?: number;
101
+ /** Space below the footer, e.g. useSafeAreaInsets().bottom (default: platform estimate). */
102
+ bottomInset?: number;
103
+ accessibilityLabel?: string;
104
+ /** Root container style (inline mode). */
105
+ style?: StyleProp<ViewStyle>;
106
+ /** Panel style. */
107
+ panelStyle?: StyleProp<ViewStyle>;
108
+ }
109
+
110
+ /**
111
+ * A side navigation drawer that follows your finger. It springs in with the scrim fading
112
+ * behind it, swipes open from the screen edge, drags closed from anywhere on the panel, settles
113
+ * with your release velocity and rubber-bands past fully open. The 'push' variant shrinks the
114
+ * screen into a rounded card and slides it aside, like modern mail apps.
115
+ */
116
+ export function Drawer({
117
+ open,
118
+ defaultOpen = false,
119
+ onOpenChange,
120
+ content,
121
+ header,
122
+ footer,
123
+ children,
124
+ side = 'left',
125
+ width = '80%',
126
+ variant = 'overlay',
127
+ edgeSwipe = true,
128
+ edgeWidth = 24,
129
+ dismissible = true,
130
+ scaleContent = true,
131
+ haptics = true,
132
+ topInset,
133
+ bottomInset,
134
+ accessibilityLabel = 'Navigation drawer',
135
+ style,
136
+ panelStyle,
137
+ }: DrawerProps) {
138
+ const { colors, dark, radius } = useKit();
139
+ const reduceMotion = useReduceMotion();
140
+ const win = useWindowDimensions();
141
+ const inline = children !== undefined;
142
+ const push = inline && variant === 'push';
143
+ const [box, setBox] = useState(inline ? 0 : win.width);
144
+ const containerW = inline ? box : win.width;
145
+ const W = Math.max(
146
+ 0,
147
+ Math.min(typeof width === 'number' ? width : (parseFloat(width) / 100) * containerW, 360, containerW - 40),
148
+ );
149
+ const dir = side === 'left' ? -1 : 1; // closed offset direction
150
+ const top = topInset ?? (inline ? INLINE_TOP : MODAL_TOP);
151
+ const bottom = bottomInset ?? (inline ? INLINE_BOTTOM : MODAL_BOTTOM);
152
+ // The screen moves (push, or the overlay scale) → its corners round in on the JS driver.
153
+ const movesScreen = inline && (push || (scaleContent && !reduceMotion));
154
+
155
+ const [inner, setInner] = useState(defaultOpen);
156
+ const isOpen = open ?? inner;
157
+ const [mounted, setMounted] = useState(isOpen); // Modal mode only
158
+ // Progress 0 (closed) → 1 (open), native-driven; every transform and opacity reads from it.
159
+ const p = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
160
+ // Same progress on the JS driver, for the screen's corner radius and edge colour (separate view).
161
+ const r = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
162
+
163
+ const live = useRef({ open: isOpen, w: W, box, reduce: reduceMotion, haptics, movesScreen, edgeSwipe, edgeWidth, dismissible });
164
+ live.current = { open: isOpen, w: W, box, reduce: reduceMotion, haptics, movesScreen, edgeSwipe, edgeWidth, dismissible };
165
+
166
+ const setOpen = useCallback(
167
+ (next: boolean) => {
168
+ if (open === undefined) setInner(next);
169
+ onOpenChange?.(next);
170
+ },
171
+ [open, onOpenChange],
172
+ );
173
+ const setOpenRef = useRef(setOpen);
174
+ setOpenRef.current = setOpen;
175
+
176
+ const target = useRef<0 | 1>(isOpen ? 1 : 0);
177
+ const settle = useCallback(
178
+ (to: 0 | 1, velocity = 0) => {
179
+ const was = target.current;
180
+ target.current = to;
181
+ if (to === 1 && !inline) setMounted(true);
182
+ if (to === 1 && was === 0 && live.current.haptics) {
183
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
184
+ }
185
+ const done = ({ finished }: { finished: boolean }) => {
186
+ if (finished && to === 0 && !inline) setMounted(false);
187
+ };
188
+ if (live.current.reduce) {
189
+ const cfg = { toValue: to, duration: 200, easing: Easing.out(Easing.cubic) };
190
+ Animated.timing(p, { ...cfg, useNativeDriver: true }).start(done);
191
+ if (live.current.movesScreen) Animated.timing(r, { ...cfg, useNativeDriver: false }).start();
192
+ return;
193
+ }
194
+ const cfg = { toValue: to, velocity, ...(to === 1 ? OPEN_SPRING : CLOSE_SPRING) };
195
+ Animated.spring(p, { ...cfg, useNativeDriver: true }).start(done);
196
+ if (live.current.movesScreen) Animated.spring(r, { ...cfg, useNativeDriver: false }).start();
197
+ },
198
+ [p, r, inline],
199
+ );
200
+
201
+ useEffect(() => {
202
+ if (isOpen && !inline) setMounted(true);
203
+ // Skip when a drag release already started a velocity spring towards this state.
204
+ if (target.current !== (isOpen ? 1 : 0)) settle(isOpen ? 1 : 0);
205
+ }, [isOpen, settle, inline]);
206
+
207
+ // Android back (inline mode; the Modal handles it otherwise) and Escape on web.
208
+ useEffect(() => {
209
+ if (!isOpen) return;
210
+ const sub = inline && Platform.OS !== 'web'
211
+ ? BackHandler.addEventListener('hardwareBackPress', () => {
212
+ setOpenRef.current(false);
213
+ return true;
214
+ })
215
+ : null;
216
+ let off: (() => void) | undefined;
217
+ if (Platform.OS === 'web' && typeof document !== 'undefined') {
218
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpenRef.current(false);
219
+ document.addEventListener('keydown', onKey);
220
+ off = () => document.removeEventListener('keydown', onKey);
221
+ }
222
+ return () => {
223
+ sub?.remove();
224
+ off?.();
225
+ };
226
+ }, [isOpen, inline]);
227
+
228
+ // Root position on the page, for the edge-swipe zone (gesture coordinates are page-based).
229
+ const rootRef = useRef<View>(null);
230
+ const rootX = useRef(0);
231
+ const measure = useCallback(() => {
232
+ rootRef.current?.measure((_x, _y, _w, _h, pageX) => {
233
+ if (typeof pageX === 'number') rootX.current = pageX;
234
+ });
235
+ }, []);
236
+
237
+ // Two responders sharing one drag: `edge` captures a horizontal swipe that starts in the edge
238
+ // zone (so taps there still reach the screen), `drag` captures horizontal drags on the open
239
+ // panel / scrim (so vertical scrolling and taps inside the panel keep working).
240
+ const start = useRef(0);
241
+ const pans = useMemo(() => {
242
+ const toward = (dx: number) => (side === 'left' ? dx : -dx); // + means "more open"
243
+ const horizontal = (g: PanResponderGestureState) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
244
+ const base = {
245
+ onPanResponderTerminationRequest: () => false,
246
+ onPanResponderGrant: () => {
247
+ p.stopAnimation((v) => (start.current = v));
248
+ r.stopAnimation();
249
+ if (!live.current.open && !inline) setMounted(true);
250
+ },
251
+ onPanResponderMove: (_: unknown, g: PanResponderGestureState) => {
252
+ const w = live.current.w || 1;
253
+ let next = start.current + toward(g.dx) / w;
254
+ if (next > 1) next = 1 + rubber((next - 1) * w, EXTRA) / w;
255
+ if (next < 0) next = 0;
256
+ p.setValue(next);
257
+ if (live.current.movesScreen) r.setValue(Math.min(1, next));
258
+ },
259
+ onPanResponderRelease: (_: unknown, g: PanResponderGestureState) => {
260
+ const w = live.current.w || 1;
261
+ const v = toward(g.vx); // px/ms, + means opening
262
+ const projected = start.current + toward(g.dx) / w + (v * 200) / w;
263
+ let to: 0 | 1 = projected > 0.5 ? 1 : 0;
264
+ if (Math.abs(v) > 0.35) to = v > 0 ? 1 : 0; // a flick decides on its own
265
+ settle(to, (v * 1000) / w);
266
+ if ((to === 1) !== live.current.open) setOpenRef.current(to === 1);
267
+ },
268
+ onPanResponderTerminate: () => settle(live.current.open ? 1 : 0),
269
+ };
270
+ const edge = PanResponder.create({
271
+ ...base,
272
+ onMoveShouldSetPanResponderCapture: (_, g) => {
273
+ const l = live.current;
274
+ if (!l.edgeSwipe || l.open || !horizontal(g) || toward(g.dx) <= 0) return false;
275
+ const x = g.x0 - rootX.current;
276
+ return side === 'left' ? x <= l.edgeWidth : x >= l.box - l.edgeWidth;
277
+ },
278
+ });
279
+ const drag = PanResponder.create({
280
+ ...base,
281
+ onMoveShouldSetPanResponderCapture: (_, g) => live.current.dismissible && live.current.open && horizontal(g),
282
+ });
283
+ return { edge, drag };
284
+ }, [p, r, side, settle, inline]);
285
+
286
+ const ctx = useMemo(
287
+ () => ({ open: isOpen, setOpen, toggle: () => setOpen(!isOpen), close: () => setOpen(false) }),
288
+ [isOpen, setOpen],
289
+ );
290
+
291
+ /* Interpolations (all native) ---------------------------------------------------------------- */
292
+ const panelX = push
293
+ ? p.interpolate({ inputRange: [0, 1], outputRange: [dir * W * 0.28, 0], extrapolate: 'clamp' })
294
+ : p.interpolate({ inputRange: [0, 1, 2], outputRange: [dir * (W + EXTRA + SHADOW_PAD), 0, -dir * W], extrapolate: 'clamp' });
295
+ const panelOpacity = push ? p.interpolate({ inputRange: [0, 0.7, 1], outputRange: [0, 0.9, 1], extrapolate: 'clamp' }) : 1;
296
+ const scrimOpacity = p.interpolate({ inputRange: [0, 1], outputRange: [0, push ? 0.5 : 1], extrapolate: 'clamp' });
297
+ const S = reduceMotion ? 1 : PUSH_SCALE;
298
+ const shift = W + PUSH_GAP - ((1 - S) * containerW) / 2;
299
+ const screenTransform = push
300
+ ? [
301
+ { translateX: p.interpolate({ inputRange: [0, 1, 2], outputRange: [0, -dir * shift, -dir * (shift + W)], extrapolate: 'clamp' }) },
302
+ { scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, S], extrapolate: 'clamp' }) },
303
+ ]
304
+ : movesScreen
305
+ ? [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96], extrapolate: 'clamp' }) }]
306
+ : undefined;
307
+ // JS driver, on the inner screen view only.
308
+ const screenRadius = r.interpolate({ inputRange: [0, 1], outputRange: [0, radius.scale(push ? 28 : 20)], extrapolate: 'clamp' });
309
+ const screenEdge = r.interpolate({
310
+ inputRange: [0, 1],
311
+ outputRange: [alpha(colors.foreground, 0), alpha(colors.foreground, dark ? 0.12 : 0.08)],
312
+ extrapolate: 'clamp',
313
+ });
314
+
315
+ /* Panel ---------------------------------------------------------------------------------------- */
316
+ const surface = dark ? colors.popover : colors.card;
317
+ const rootBg = push ? (dark ? colors.card : colors.muted) : dark ? colors.background : colors.foreground;
318
+ const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
319
+ const corner = push ? 0 : radius.scale(24);
320
+ const round =
321
+ side === 'left'
322
+ ? { borderTopRightRadius: corner, borderBottomRightRadius: corner }
323
+ : { borderTopLeftRadius: corner, borderBottomLeftRadius: corner };
324
+
325
+ const panel = (
326
+ <Animated.View
327
+ {...(dismissible ? pans.drag.panHandlers : null)}
328
+ accessibilityViewIsModal={isOpen}
329
+ accessibilityLabel={accessibilityLabel}
330
+ importantForAccessibility={isOpen ? 'yes' : 'no-hide-descendants'}
331
+ pointerEvents={push && !isOpen ? 'none' : 'auto'}
332
+ style={[
333
+ styles.panel,
334
+ side === 'left' ? { left: -EXTRA } : { right: -EXTRA },
335
+ { width: W + EXTRA, opacity: panelOpacity, transform: [{ translateX: panelX }] },
336
+ !push && {
337
+ shadowColor: '#000',
338
+ shadowOpacity: dark ? 0.45 : 0.12,
339
+ shadowRadius: 28,
340
+ shadowOffset: { width: -dir * 8, height: 0 },
341
+ elevation: 24,
342
+ },
343
+ panelStyle,
344
+ ]}
345
+ >
346
+ <View
347
+ style={[
348
+ styles.flex,
349
+ round,
350
+ !push && {
351
+ backgroundColor: surface,
352
+ shadowColor: '#000',
353
+ shadowOpacity: dark ? 0.3 : 0.06,
354
+ shadowRadius: 4,
355
+ shadowOffset: { width: -dir * 1, height: 0 },
356
+ },
357
+ ]}
358
+ >
359
+ <View
360
+ style={[
361
+ styles.flex,
362
+ styles.clip,
363
+ round,
364
+ !push && { borderColor: hairline },
365
+ !push && (side === 'left' ? styles.borderRight : styles.borderLeft),
366
+ ]}
367
+ >
368
+ <SurfaceContext.Provider value={push ? rootBg : surface}>
369
+ <View style={[styles.flex, side === 'left' ? { paddingLeft: EXTRA } : { paddingRight: EXTRA }]}>
370
+ <View
371
+ style={[
372
+ styles.chromeTop,
373
+ { paddingTop: top },
374
+ header ? [styles.chromeTopDivided, { borderBottomColor: hairline }] : null,
375
+ ]}
376
+ >
377
+ {header ?? <DrawerHandle side={side} />}
378
+ </View>
379
+ <ScrollView
380
+ style={styles.flex}
381
+ contentContainerStyle={styles.content}
382
+ showsVerticalScrollIndicator={false}
383
+ keyboardShouldPersistTaps="handled"
384
+ >
385
+ {content}
386
+ </ScrollView>
387
+ {footer ? (
388
+ <View style={[styles.chromeBottom, { paddingBottom: bottom, borderTopColor: hairline }]}>{footer}</View>
389
+ ) : (
390
+ <View style={{ height: bottom }} />
391
+ )}
392
+ </View>
393
+ </SurfaceContext.Provider>
394
+ </View>
395
+ </View>
396
+ </Animated.View>
397
+ );
398
+
399
+ const scrim = (
400
+ <Animated.View
401
+ {...(dismissible ? pans.drag.panHandlers : null)}
402
+ pointerEvents={isOpen ? 'auto' : 'none'}
403
+ style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: scrimOpacity }]}
404
+ >
405
+ <Pressable
406
+ style={StyleSheet.absoluteFill}
407
+ onPress={() => dismissible && setOpen(false)}
408
+ accessibilityRole="button"
409
+ accessibilityLabel="Close drawer"
410
+ />
411
+ </Animated.View>
412
+ );
413
+
414
+ if (!inline) {
415
+ return (
416
+ <DrawerContext.Provider value={ctx}>
417
+ <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={() => setOpen(false)}>
418
+ {scrim}
419
+ {panel}
420
+ </Modal>
421
+ </DrawerContext.Provider>
422
+ );
423
+ }
424
+
425
+ return (
426
+ <DrawerContext.Provider value={ctx}>
427
+ <View
428
+ ref={rootRef}
429
+ {...pans.edge.panHandlers}
430
+ onTouchStart={measure}
431
+ onLayout={(e: LayoutChangeEvent) => {
432
+ setBox(e.nativeEvent.layout.width);
433
+ measure();
434
+ }}
435
+ style={[styles.root, movesScreen && { backgroundColor: rootBg }, style]}
436
+ >
437
+ {push && box ? panel : null}
438
+ <Animated.View
439
+ importantForAccessibility={isOpen ? 'no-hide-descendants' : 'auto'}
440
+ style={[
441
+ StyleSheet.absoluteFill,
442
+ screenTransform && { transform: screenTransform },
443
+ push &&
444
+ Platform.OS === 'ios' && {
445
+ shadowColor: '#000',
446
+ shadowOpacity: dark ? 0.5 : 0.16,
447
+ shadowRadius: 24,
448
+ shadowOffset: { width: dir * 6, height: 8 },
449
+ },
450
+ ]}
451
+ >
452
+ <Animated.View
453
+ style={[
454
+ styles.flex,
455
+ movesScreen && {
456
+ backgroundColor: colors.background,
457
+ borderRadius: screenRadius,
458
+ borderWidth: StyleSheet.hairlineWidth,
459
+ borderColor: screenEdge,
460
+ overflow: 'hidden',
461
+ elevation: push ? 16 : 0,
462
+ },
463
+ ]}
464
+ >
465
+ {children}
466
+ {push ? scrim : null}
467
+ </Animated.View>
468
+ </Animated.View>
469
+ {push ? null : scrim}
470
+ {!push && box ? panel : null}
471
+ </View>
472
+ </DrawerContext.Provider>
473
+ );
474
+ }
475
+
476
+ /** Top spacing used when you do not pass `header`. */
477
+ export function DrawerHandle(_: { side?: DrawerSide }) {
478
+ return <View style={styles.handleSpacer} />;
479
+ }
480
+
481
+ export interface DrawerHeaderProps {
482
+ title?: string;
483
+ subtitle?: string;
484
+ /** Optional leading node (avatar, logo). */
485
+ leading?: React.ReactNode;
486
+ /** Shows a close control on the trailing edge (default false). */
487
+ showClose?: boolean;
488
+ onClose?: () => void;
489
+ children?: React.ReactNode;
490
+ style?: StyleProp<ViewStyle>;
491
+ }
492
+
493
+ /** Title block with optional leading node and close button — drop into `header`. */
494
+ export function DrawerHeader({ title, subtitle, leading, showClose = false, onClose, children, style }: DrawerHeaderProps) {
495
+ const { colors, radius, dark } = useKit();
496
+ const drawerCtx = useContext(DrawerContext);
497
+ const close = onClose ?? (() => drawerCtx?.close());
498
+ const canClose = showClose || onClose !== undefined;
499
+ return (
500
+ <View style={[styles.header, style]}>
501
+ {leading ? <View>{leading}</View> : null}
502
+ <View style={styles.headerText}>
503
+ {title ? (
504
+ <Text accessibilityRole="header" style={[styles.headerTitle, { color: colors.foreground }]} numberOfLines={1}>
505
+ {title}
506
+ </Text>
507
+ ) : null}
508
+ {subtitle ? (
509
+ <Text style={[styles.headerSub, { color: colors.mutedForeground }]} numberOfLines={1}>
510
+ {subtitle}
511
+ </Text>
512
+ ) : null}
513
+ {children}
514
+ </View>
515
+ {canClose ? (
516
+ <Pressable
517
+ onPress={close}
518
+ accessibilityRole="button"
519
+ accessibilityLabel="Close menu"
520
+ hitSlop={8}
521
+ style={({ pressed }) => [
522
+ styles.closeBtn,
523
+ {
524
+ borderRadius: radius.capsule(34),
525
+ backgroundColor: alpha(colors.foreground, pressed ? (dark ? 0.16 : 0.1) : dark ? 0.08 : 0.05),
526
+ },
527
+ ]}
528
+ >
529
+ <DrawerIcon name="x" size={16} color={colors.mutedForeground} />
530
+ </Pressable>
531
+ ) : null}
532
+ </View>
533
+ );
534
+ }
535
+
536
+ /* -------------------------------------------------------------------------------------------------
537
+ * DrawerProfile
538
+ * -----------------------------------------------------------------------------------------------*/
539
+
540
+ export type DrawerPresence = 'online' | 'away' | 'busy' | 'offline';
541
+
542
+ export interface DrawerProfileProps {
543
+ name: string;
544
+ /** Email or any second line (plan, role, workspace URL). */
545
+ email?: string;
546
+ /** Avatar image URL. Without it, initials sit on a soft solid tint derived from the name. */
547
+ src?: string;
548
+ /** Presence dot on the avatar. */
549
+ status?: DrawerPresence;
550
+ /** 'row' — avatar beside the text · 'stacked' — large avatar above (Gmail-style). */
551
+ layout?: 'row' | 'stacked';
552
+ /** Makes the block a button (e.g. an account or workspace switcher). */
553
+ onPress?: () => void;
554
+ /** Trailing node. Defaults to an up/down chevron when `onPress` is set; `null` hides it. */
555
+ accessory?: React.ReactNode;
556
+ accessibilityHint?: string;
557
+ style?: StyleProp<ViewStyle>;
558
+ }
559
+
560
+ /** Account block for the drawer header: initials (or image) avatar with presence, name and email. */
561
+ export function DrawerProfile({
562
+ name,
563
+ email,
564
+ src,
565
+ status,
566
+ layout = 'row',
567
+ onPress,
568
+ accessory,
569
+ accessibilityHint,
570
+ style,
571
+ }: DrawerProfileProps) {
572
+ const { colors, radius, dark } = useKit();
573
+ const press = useRef(new Animated.Value(0)).current;
574
+ const stacked = layout === 'stacked';
575
+ const trailing =
576
+ accessory !== undefined ? (
577
+ accessory
578
+ ) : onPress ? (
579
+ <View
580
+ style={[
581
+ styles.profileChevron,
582
+ { borderRadius: radius.capsule(30), backgroundColor: alpha(colors.foreground, dark ? 0.08 : 0.05) },
583
+ ]}
584
+ >
585
+ <DrawerIcon name="chevrons" size={16} color={colors.mutedForeground} />
586
+ </View>
587
+ ) : null;
588
+ const text = (
589
+ <View style={styles.profileText}>
590
+ <Text numberOfLines={1} style={[styles.profileName, { color: colors.foreground }, stacked && styles.profileNameLg]}>
591
+ {name}
592
+ </Text>
593
+ {email ? (
594
+ <Text numberOfLines={1} style={[styles.profileEmail, { color: colors.mutedForeground }]}>
595
+ {email}
596
+ </Text>
597
+ ) : null}
598
+ </View>
599
+ );
600
+ const spring = (to: number) => Animated.spring(press, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
601
+ return (
602
+ <Animated.View style={[{ transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.98] }) }] }, style]}>
603
+ <Pressable
604
+ disabled={!onPress}
605
+ onPress={onPress}
606
+ onPressIn={() => spring(1)}
607
+ onPressOut={() => spring(0)}
608
+ accessibilityRole={onPress ? 'button' : undefined}
609
+ accessibilityLabel={email ? `${name}, ${email}` : name}
610
+ accessibilityHint={accessibilityHint ?? (onPress ? 'Switch account' : undefined)}
611
+ style={({ pressed }) => [
612
+ stacked ? styles.profileStacked : styles.profileRow,
613
+ {
614
+ borderRadius: radius.lg,
615
+ backgroundColor: pressed && onPress ? alpha(colors.foreground, dark ? 0.08 : 0.05) : 'transparent',
616
+ },
617
+ ]}
618
+ >
619
+ {stacked ? (
620
+ <>
621
+ <View style={styles.profileTop}>
622
+ <ProfileAvatar name={name} src={src} size={56} status={status} />
623
+ {trailing}
624
+ </View>
625
+ {text}
626
+ </>
627
+ ) : (
628
+ <>
629
+ <ProfileAvatar name={name} src={src} size={44} status={status} />
630
+ {text}
631
+ {trailing}
632
+ </>
633
+ )}
634
+ </Pressable>
635
+ </Animated.View>
636
+ );
637
+ }
638
+
639
+ function ProfileAvatar({ name, src, size, status }: { name: string; src?: string; size: number; status?: DrawerPresence }) {
640
+ const { colors, radius, dark } = useKit();
641
+ const ring = useContext(SurfaceContext) ?? colors.card;
642
+ const hue = nameColor(name);
643
+ const ink = dark ? mix(hue, colors.foreground, 0.35) : mix(hue, colors.foreground, 0.3);
644
+ const initials = name
645
+ .split(/\s+/)
646
+ .filter(Boolean)
647
+ .slice(0, 2)
648
+ .map((w) => w[0]?.toUpperCase() ?? '')
649
+ .join('');
650
+ const dot = Math.round(size * 0.27);
651
+ const presence: Record<DrawerPresence, string> = {
652
+ online: colors.success,
653
+ away: colors.warning,
654
+ busy: colors.danger,
655
+ offline: colors.mutedForeground,
656
+ };
657
+ return (
658
+ <View style={{ width: size, height: size }} accessible={false}>
659
+ <View
660
+ style={[
661
+ styles.clip,
662
+ styles.center,
663
+ {
664
+ width: size,
665
+ height: size,
666
+ borderRadius: radius.capsule(size),
667
+ backgroundColor: alpha(hue, dark ? 0.24 : 0.16),
668
+ borderWidth: StyleSheet.hairlineWidth,
669
+ borderColor: alpha(hue, dark ? 0.36 : 0.24),
670
+ },
671
+ ]}
672
+ >
673
+ <Text style={{ color: ink, fontSize: size * 0.36, fontWeight: '400', letterSpacing: -0.2 }}>{initials}</Text>
674
+ {src ? <Image source={{ uri: src }} alt="" style={StyleSheet.absoluteFill} /> : null}
675
+ </View>
676
+ {status ? (
677
+ <View
678
+ style={[
679
+ styles.presence,
680
+ {
681
+ width: dot + 4,
682
+ height: dot + 4,
683
+ borderRadius: radius.capsule(dot + 4),
684
+ backgroundColor: presence[status],
685
+ borderColor: ring,
686
+ },
687
+ ]}
688
+ />
689
+ ) : null}
690
+ </View>
691
+ );
692
+ }
693
+
694
+ /* -------------------------------------------------------------------------------------------------
695
+ * Icons
696
+ * -----------------------------------------------------------------------------------------------*/
697
+
698
+ const ICONS = {
699
+ home: ['M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z', 'M9 22V12h6v10'],
700
+ inbox: [
701
+ 'M22 12h-6l-2 3h-4l-2-3H2',
702
+ 'M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z',
703
+ ],
704
+ send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
705
+ file: ['M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z', 'M14 2v6h6', 'M8 13h8M8 17h5'],
706
+ archive: ['M21 8v13H3V8', 'M1 3h22v5H1z', 'M10 12h4'],
707
+ trash: ['M3 6h18', 'M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6', 'M10 11v6M14 11v6', 'M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2'],
708
+ alert: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M12 8v4M12 16h.01'],
709
+ calendar: ['M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z', 'M16 2v4M8 2v4M3 10h18'],
710
+ folder: ['M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z'],
711
+ users: [
712
+ 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2',
713
+ 'M9 11a4 4 0 100-8 4 4 0 000 8z',
714
+ 'M23 21v-2a4 4 0 00-3-3.87',
715
+ 'M16 3.13a4 4 0 010 7.75',
716
+ ],
717
+ star: ['M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'],
718
+ chart: ['M18 20V10M12 20V4M6 20v-6'],
719
+ layers: ['M12 2L2 7l10 5 10-5-10-5z', 'M2 17l10 5 10-5', 'M2 12l10 5 10-5'],
720
+ target: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M12 16a4 4 0 100-8 4 4 0 000 8z'],
721
+ search: ['M11 19a8 8 0 100-16 8 8 0 000 16z', 'M21 21l-4.35-4.35'],
722
+ music: ['M9 18V5l12-2v13', 'M6 21a3 3 0 100-6 3 3 0 000 6z', 'M18 19a3 3 0 100-6 3 3 0 000 6z'],
723
+ cloud: ['M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z'],
724
+ settings: ['M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6'],
725
+ help: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3', 'M12 17h.01'],
726
+ logout: ['M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
727
+ bell: ['M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9', 'M13.73 21a2 2 0 01-3.46 0'],
728
+ bookmark: ['M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z'],
729
+ tag: ['M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z', 'M7 7h.01'],
730
+ plus: ['M12 5v14M5 12h14'],
731
+ menu: ['M3 6h18M3 12h18M3 18h18'],
732
+ filter: ['M3 6h18M6 12h12M10 18h4'],
733
+ chevrons: ['M7 9l5-5 5 5', 'M7 15l5 5 5-5'],
734
+ chevronRight: ['M9 18l6-6-6-6'],
735
+ x: ['M18 6L6 18M6 6l12 12'],
736
+ check: ['M20 6L9 17l-5-5'],
737
+ } as const;
738
+
739
+ export type DrawerIconName = keyof typeof ICONS;
740
+
741
+ export interface DrawerIconProps {
742
+ name: DrawerIconName;
743
+ size?: number;
744
+ color?: string;
745
+ strokeWidth?: number;
746
+ }
747
+
748
+ /** The drawer's line icons (1.8 stroke), exported for menu buttons and headers. */
749
+ export function DrawerIcon({ name, size = 22, color, strokeWidth = 1.8 }: DrawerIconProps) {
750
+ const { colors } = useKit();
751
+ return (
752
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
753
+ {ICONS[name].map((d) => (
754
+ <Path
755
+ key={d}
756
+ d={d}
757
+ stroke={color ?? colors.foreground}
758
+ strokeWidth={strokeWidth}
759
+ strokeLinecap="round"
760
+ strokeLinejoin="round"
761
+ />
762
+ ))}
763
+ </Svg>
764
+ );
765
+ }
766
+
767
+ /* -------------------------------------------------------------------------------------------------
768
+ * DrawerItem
769
+ * -----------------------------------------------------------------------------------------------*/
770
+
771
+ export interface DrawerItemProps {
772
+ label: string;
773
+ /** A built-in icon name or your own node (e.g. a label colour dot). */
774
+ icon?: DrawerIconName | React.ReactNode;
775
+ /** A count or short text on the right. 0 hides it. */
776
+ badge?: number | string;
777
+ /** 'neutral' plain muted count (accent ink when active) · 'accent' soft tinted pill · 'danger' solid pill for alerts. */
778
+ badgeTone?: 'neutral' | 'accent' | 'danger';
779
+ /** Highlights the row as the current destination with a tinted pill. */
780
+ active?: boolean;
781
+ /** 'danger' tints the icon and label (e.g. Log out). */
782
+ tone?: 'default' | 'danger';
783
+ onPress?: () => void;
784
+ /** Close the surrounding Drawer after pressing (default true). */
785
+ closeOnPress?: boolean;
786
+ /** Active colour (default: theme accent). */
787
+ accent?: string;
788
+ /** Selection haptic on press (default true). */
789
+ haptics?: boolean;
790
+ /** Anything after the badge, e.g. a chevron or a Switch. */
791
+ trailing?: React.ReactNode;
792
+ disabled?: boolean;
793
+ style?: StyleProp<ViewStyle>;
794
+ }
795
+
796
+ const ITEM_H = 46;
797
+
798
+ /** A drawer row: icon, label and count badge, with a spring-in accent pill when active. */
799
+ export function DrawerItem({
800
+ label,
801
+ icon,
802
+ badge,
803
+ badgeTone = 'neutral',
804
+ active = false,
805
+ tone = 'default',
806
+ onPress,
807
+ closeOnPress = true,
808
+ accent,
809
+ haptics = true,
810
+ trailing,
811
+ disabled,
812
+ style,
813
+ }: DrawerItemProps) {
814
+ const { colors, radius, dark } = useKit();
815
+ const drawer = useContext(DrawerContext);
816
+ const reduceMotion = useReduceMotion();
817
+ const danger = tone === 'danger';
818
+ const tint = danger ? colors.danger : (accent ?? colors.accent);
819
+ const ink = dark ? tint : mix(tint, colors.foreground, 0.22);
820
+
821
+ // Native-driven: pill presence, icon pop, badge pop, press scale — each on its own value.
822
+ const on = useRef(new Animated.Value(active ? 1 : 0)).current;
823
+ const pop = useRef(new Animated.Value(1)).current;
824
+ const badgePop = useRef(new Animated.Value(1)).current;
825
+ const press = useRef(new Animated.Value(0)).current;
826
+ const first = useRef(true);
827
+ const firstBadge = useRef(true);
828
+
829
+ useEffect(() => {
830
+ Animated.spring(on, { toValue: active ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
831
+ if (first.current) {
832
+ first.current = false;
833
+ return;
834
+ }
835
+ if (active && !reduceMotion) {
836
+ pop.setValue(0.78);
837
+ Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
838
+ }
839
+ // eslint-disable-next-line react-hooks/exhaustive-deps
840
+ }, [active]);
841
+
842
+ useEffect(() => {
843
+ if (firstBadge.current) {
844
+ firstBadge.current = false;
845
+ return;
846
+ }
847
+ if (reduceMotion) return;
848
+ badgePop.setValue(0.6);
849
+ Animated.spring(badgePop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
850
+ // eslint-disable-next-line react-hooks/exhaustive-deps
851
+ }, [badge]);
852
+
853
+ const showBadge = badge !== undefined && badge !== 0 && badge !== '';
854
+ // Neutral counts are plain text (iOS / Material 3 style); 'accent' gets a soft tinted pill and
855
+ // 'danger' a solid one. On the active row the count takes the row's ink colour.
856
+ const badgeColors =
857
+ badgeTone === 'danger'
858
+ ? { bg: colors.danger, fg: colors.dangerForeground }
859
+ : badgeTone === 'accent'
860
+ ? { bg: alpha(tint, dark ? 0.24 : 0.14), fg: ink }
861
+ : { bg: 'transparent', fg: active ? ink : colors.mutedForeground };
862
+ const badgePill = badgeTone !== 'neutral';
863
+ const iconColor = active ? tint : danger ? colors.danger : colors.mutedForeground;
864
+ const labelColor = active ? ink : danger ? colors.danger : colors.foreground;
865
+ const pill = radius.capsule(ITEM_H);
866
+ const spring = (to: number) => Animated.spring(press, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
867
+
868
+ return (
869
+ <Animated.View style={{ transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.98] }) }] }}>
870
+ <Pressable
871
+ onPress={() => {
872
+ if (haptics) Haptics.selectionAsync().catch(() => {});
873
+ onPress?.();
874
+ if (closeOnPress) drawer?.close();
875
+ }}
876
+ disabled={disabled}
877
+ accessibilityRole="menuitem"
878
+ accessibilityLabel={showBadge ? `${label}, ${badge}` : label}
879
+ accessibilityState={{ selected: active, disabled: !!disabled }}
880
+ onPressIn={() => spring(1)}
881
+ onPressOut={() => spring(0)}
882
+ style={({ pressed }) => [
883
+ styles.item,
884
+ {
885
+ borderRadius: pill,
886
+ opacity: disabled ? 0.45 : 1,
887
+ backgroundColor: pressed && !active ? alpha(colors.foreground, dark ? 0.07 : 0.045) : 'transparent',
888
+ },
889
+ style,
890
+ ]}
891
+ >
892
+ <Animated.View
893
+ pointerEvents="none"
894
+ style={[
895
+ StyleSheet.absoluteFill,
896
+ {
897
+ borderRadius: pill,
898
+ backgroundColor: alpha(tint, dark ? 0.18 : 0.12),
899
+ opacity: on,
900
+ transform: [{ scaleX: on.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }) }],
901
+ },
902
+ ]}
903
+ />
904
+ {icon ? (
905
+ <Animated.View style={[styles.itemIcon, { transform: [{ scale: pop }] }]}>
906
+ {typeof icon === 'string' ? (
907
+ <DrawerIcon name={icon as DrawerIconName} size={21} color={iconColor} strokeWidth={active ? 2.1 : 1.8} />
908
+ ) : (
909
+ icon
910
+ )}
911
+ </Animated.View>
912
+ ) : null}
913
+ <Text numberOfLines={1} style={[styles.itemLabel, { color: labelColor }]}>
914
+ {label}
915
+ </Text>
916
+ {showBadge ? (
917
+ <Animated.View
918
+ style={[
919
+ styles.badge,
920
+ !badgePill && styles.badgePlain,
921
+ { borderRadius: radius.capsule(22), backgroundColor: badgeColors.bg, transform: [{ scale: badgePop }] },
922
+ ]}
923
+ >
924
+ <Text style={[styles.badgeText, { color: badgeColors.fg }]}>
925
+ {typeof badge === 'number' && badge > 99 ? '99+' : badge}
926
+ </Text>
927
+ </Animated.View>
928
+ ) : null}
929
+ {trailing}
930
+ </Pressable>
931
+ </Animated.View>
932
+ );
933
+ }
934
+
935
+ export interface DrawerSectionProps {
936
+ title: string;
937
+ /** Node on the right of the heading, e.g. a small "+" button. */
938
+ trailing?: React.ReactNode;
939
+ /** Hairline above the heading, separating it from the group before (default true). */
940
+ divider?: boolean;
941
+ style?: StyleProp<ViewStyle>;
942
+ }
943
+
944
+ /** A quiet heading between groups of DrawerItems, with a hairline divider above it. */
945
+ export function DrawerSection({ title, trailing, divider = true, style }: DrawerSectionProps) {
946
+ const { colors, dark } = useKit();
947
+ return (
948
+ <View style={style}>
949
+ {divider ? <View style={[styles.divider, { backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]} /> : null}
950
+ <View style={[styles.section, !divider && styles.sectionFlush]}>
951
+ <Text accessibilityRole="header" style={[styles.sectionText, { color: colors.mutedForeground }]}>
952
+ {title}
953
+ </Text>
954
+ {trailing}
955
+ </View>
956
+ </View>
957
+ );
958
+ }
959
+
960
+ export interface DrawerFooterProps {
961
+ label: string;
962
+ /** Muted second line, e.g. "12.4 GB of 15 GB used". */
963
+ detail?: string;
964
+ icon?: DrawerIconName | React.ReactNode;
965
+ /** 0–1: draws a thin meter under the label (turns warning above 90%). */
966
+ progress?: number;
967
+ onPress?: () => void;
968
+ /** Node on the right, e.g. an icon button. */
969
+ trailing?: React.ReactNode;
970
+ style?: StyleProp<ViewStyle>;
971
+ }
972
+
973
+ /** A compact footer row — storage meter, app version, sync status — for the `footer` slot. */
974
+ export function DrawerFooter({ label, detail, icon, progress, onPress, trailing, style }: DrawerFooterProps) {
975
+ const { colors, radius, dark } = useKit();
976
+ const pct = progress === undefined ? undefined : Math.max(0, Math.min(1, progress));
977
+ return (
978
+ <Pressable
979
+ disabled={!onPress}
980
+ onPress={onPress}
981
+ accessibilityRole={onPress ? 'button' : undefined}
982
+ accessibilityLabel={detail ? `${label}, ${detail}` : label}
983
+ accessibilityValue={pct !== undefined ? { min: 0, max: 100, now: Math.round(pct * 100) } : undefined}
984
+ style={({ pressed }) => [
985
+ styles.footerRow,
986
+ {
987
+ borderRadius: radius.lg,
988
+ backgroundColor: pressed && onPress ? alpha(colors.foreground, dark ? 0.07 : 0.045) : 'transparent',
989
+ },
990
+ style,
991
+ ]}
992
+ >
993
+ {icon ? (
994
+ <View style={styles.itemIcon}>
995
+ {typeof icon === 'string' ? <DrawerIcon name={icon as DrawerIconName} size={20} color={colors.mutedForeground} /> : icon}
996
+ </View>
997
+ ) : null}
998
+ <View style={styles.footerText}>
999
+ <Text numberOfLines={1} style={[styles.footerLabel, { color: colors.foreground }]}>
1000
+ {label}
1001
+ </Text>
1002
+ {pct !== undefined ? (
1003
+ <View style={[styles.meter, { borderRadius: radius.capsule(4), backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]}>
1004
+ <View
1005
+ style={{
1006
+ width: `${pct * 100}%`,
1007
+ height: '100%',
1008
+ borderRadius: radius.capsule(4),
1009
+ backgroundColor: pct > 0.9 ? colors.warning : colors.accent,
1010
+ }}
1011
+ />
1012
+ </View>
1013
+ ) : null}
1014
+ {detail ? (
1015
+ <Text numberOfLines={1} style={[styles.footerDetail, { color: colors.mutedForeground }]}>
1016
+ {detail}
1017
+ </Text>
1018
+ ) : null}
1019
+ </View>
1020
+ {trailing}
1021
+ </Pressable>
1022
+ );
1023
+ }
1024
+
1025
+ /* -------------------------------------------------------------------------------------------------
1026
+ * Helpers
1027
+ * -----------------------------------------------------------------------------------------------*/
1028
+
1029
+ /** Diminishing resistance past the limits (approaches `dimension`). */
1030
+ function rubber(distance: number, dimension: number) {
1031
+ return (distance * dimension * 0.55) / (dimension + 0.55 * distance);
1032
+ }
1033
+
1034
+ function useReduceMotion() {
1035
+ const [reduce, setReduce] = useState(false);
1036
+ useEffect(() => {
1037
+ let alive = true;
1038
+ AccessibilityInfo.isReduceMotionEnabled()
1039
+ .then((v) => alive && setReduce(v))
1040
+ .catch(() => {});
1041
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
1042
+ return () => {
1043
+ alive = false;
1044
+ sub.remove();
1045
+ };
1046
+ }, []);
1047
+ return reduce;
1048
+ }
1049
+
1050
+ function parseHex(hex: string): [number, number, number] | null {
1051
+ const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;
1052
+ if (!/^#[0-9a-fA-F]{6}/.test(full)) return null;
1053
+ const n = parseInt(full.slice(1, 7), 16);
1054
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
1055
+ }
1056
+
1057
+ function toHex(r: number, g: number, b: number) {
1058
+ return '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
1059
+ }
1060
+
1061
+ /** Blend `a` toward `b` by `t` (hex colours; returns `a` if either can't be parsed). */
1062
+ function mix(a: string, b: string, t: number) {
1063
+ const x = parseHex(a);
1064
+ const y = parseHex(b);
1065
+ if (!x || !y) return a;
1066
+ return toHex(x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t);
1067
+ }
1068
+
1069
+ function hsl(h: number, s: number, l: number) {
1070
+ const k = (n: number) => (n + h / 30) % 12;
1071
+ const a = s * Math.min(l, 1 - l);
1072
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1073
+ return toHex(f(0) * 255, f(8) * 255, f(4) * 255);
1074
+ }
1075
+
1076
+ /** One solid, calm colour per name, so the same person always gets the same avatar tint. */
1077
+ function nameColor(name: string) {
1078
+ let h = 0;
1079
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
1080
+ return hsl(h, 0.55, 0.52);
1081
+ }
1082
+
1083
+ const styles = StyleSheet.create({
1084
+ // 'clip' on web: an overflow:hidden box can still be scrolled by focus, which would shift the panel.
1085
+ root: Platform.OS === 'web' ? ({ flex: 1, overflow: 'clip' } as object) : { flex: 1, overflow: 'hidden' },
1086
+ flex: { flex: 1 },
1087
+ clip: { overflow: 'hidden' },
1088
+ center: { alignItems: 'center', justifyContent: 'center' },
1089
+ panel: { position: 'absolute', top: 0, bottom: 0 },
1090
+ borderRight: { borderRightWidth: StyleSheet.hairlineWidth },
1091
+ borderLeft: { borderLeftWidth: StyleSheet.hairlineWidth },
1092
+ chromeTop: { paddingHorizontal: 10, paddingBottom: 6 },
1093
+ chromeTopDivided: { borderBottomWidth: StyleSheet.hairlineWidth, paddingBottom: 10, marginBottom: 4 },
1094
+ chromeBottom: { paddingHorizontal: 10, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth },
1095
+ handleSpacer: { height: 8 },
1096
+ content: { paddingHorizontal: 10, paddingTop: 4, paddingBottom: 12, gap: 2 },
1097
+ header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 8, paddingVertical: 8 },
1098
+ headerText: { flex: 1, gap: 2, minWidth: 0 },
1099
+ headerTitle: { fontSize: 20, lineHeight: 26, fontWeight: '400', letterSpacing: -0.4 },
1100
+ headerSub: { fontSize: 13, lineHeight: 18, fontWeight: '400' },
1101
+ closeBtn: { width: 34, height: 34, alignItems: 'center', justifyContent: 'center' },
1102
+ profileRow: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 8 },
1103
+ profileStacked: { gap: 12, padding: 8 },
1104
+ profileTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' },
1105
+ profileText: { flex: 1, minWidth: 0, gap: 1 },
1106
+ profileName: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.2 },
1107
+ profileNameLg: { fontSize: 18, lineHeight: 23, letterSpacing: -0.3 },
1108
+ profileEmail: { fontSize: 13, lineHeight: 18, fontWeight: '400' },
1109
+ profileChevron: { width: 30, height: 30, alignItems: 'center', justifyContent: 'center' },
1110
+ presence: { position: 'absolute', right: -2, bottom: -2, borderWidth: 2 },
1111
+ item: { minHeight: ITEM_H, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, gap: 14 },
1112
+ itemIcon: { width: 24, alignItems: 'center', justifyContent: 'center' },
1113
+ itemLabel: { flex: 1, fontSize: 15.5, fontWeight: '400', letterSpacing: -0.15 },
1114
+ badge: { minWidth: 22, height: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
1115
+ badgePlain: { paddingHorizontal: 2, minWidth: 0 },
1116
+ badgeText: { fontSize: 13, fontWeight: '400', fontVariant: ['tabular-nums'] },
1117
+ section: {
1118
+ flexDirection: 'row',
1119
+ alignItems: 'center',
1120
+ justifyContent: 'space-between',
1121
+ minHeight: 36,
1122
+ paddingLeft: 14,
1123
+ paddingRight: 10,
1124
+ paddingTop: 6,
1125
+ paddingBottom: 2,
1126
+ },
1127
+ sectionFlush: { paddingTop: 10 },
1128
+ sectionText: { fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0.1 },
1129
+ divider: { height: StyleSheet.hairlineWidth, marginHorizontal: 14, marginTop: 10, marginBottom: 4 },
1130
+ footerRow: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 12, paddingVertical: 8 },
1131
+ footerText: { flex: 1, minWidth: 0, gap: 5 },
1132
+ footerLabel: { fontSize: 14, fontWeight: '400' },
1133
+ footerDetail: { fontSize: 12, fontWeight: '400' },
1134
+ meter: { height: 4, overflow: 'hidden' },
1135
+ });