@expokit/cli 0.0.0-stage → 0.3.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 (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/bin/expo-kit.js +1266 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +345 -0
  6. package/package.json +49 -4
  7. package/registry/_docs/accordion.json +115 -0
  8. package/registry/_docs/action-sheet.json +138 -0
  9. package/registry/_docs/alert.json +155 -0
  10. package/registry/_docs/avatar.json +227 -0
  11. package/registry/_docs/badge.json +148 -0
  12. package/registry/_docs/bottom-nav.json +143 -0
  13. package/registry/_docs/bottom-sheet.json +186 -0
  14. package/registry/_docs/bullet.json +27 -0
  15. package/registry/_docs/button-3d.json +138 -0
  16. package/registry/_docs/button.json +131 -0
  17. package/registry/_docs/calendar.json +198 -0
  18. package/registry/_docs/card-stack.json +124 -0
  19. package/registry/_docs/card.json +131 -0
  20. package/registry/_docs/carousel.json +122 -0
  21. package/registry/_docs/chat.json +184 -0
  22. package/registry/_docs/checkbox.json +109 -0
  23. package/registry/_docs/checkout.json +105 -0
  24. package/registry/_docs/chip-group.json +132 -0
  25. package/registry/_docs/color-swatch.json +82 -0
  26. package/registry/_docs/confetti.json +102 -0
  27. package/registry/_docs/coupon.json +192 -0
  28. package/registry/_docs/cube-pager.json +100 -0
  29. package/registry/_docs/dashboard.json +123 -0
  30. package/registry/_docs/date-picker.json +269 -0
  31. package/registry/_docs/dialog.json +186 -0
  32. package/registry/_docs/drawer.json +199 -0
  33. package/registry/_docs/fab.json +144 -0
  34. package/registry/_docs/flip-card.json +127 -0
  35. package/registry/_docs/floating-input.json +211 -0
  36. package/registry/_docs/gift-card.json +124 -0
  37. package/registry/_docs/glass-button.json +142 -0
  38. package/registry/_docs/glass-nav-bar.json +102 -0
  39. package/registry/_docs/glass-search-bar.json +114 -0
  40. package/registry/_docs/glass-segmented.json +94 -0
  41. package/registry/_docs/glass-sheet.json +136 -0
  42. package/registry/_docs/glass-tab-bar.json +100 -0
  43. package/registry/_docs/glass.json +94 -0
  44. package/registry/_docs/inbox.json +75 -0
  45. package/registry/_docs/isometric-bar.json +75 -0
  46. package/registry/_docs/kit.json +14 -0
  47. package/registry/_docs/kpi-card.json +14 -0
  48. package/registry/_docs/like-button.json +92 -0
  49. package/registry/_docs/liquidglass-tab.json +30 -0
  50. package/registry/_docs/list-item.json +216 -0
  51. package/registry/_docs/loyalty-card.json +124 -0
  52. package/registry/_docs/morph-button.json +128 -0
  53. package/registry/_docs/number-ticker.json +112 -0
  54. package/registry/_docs/offers.json +81 -0
  55. package/registry/_docs/onboarding.json +69 -0
  56. package/registry/_docs/otp-input.json +158 -0
  57. package/registry/_docs/pagination-dots.json +108 -0
  58. package/registry/_docs/parallax-layers.json +104 -0
  59. package/registry/_docs/parallax-scroll.json +210 -0
  60. package/registry/_docs/password-input.json +112 -0
  61. package/registry/_docs/payment-card.json +169 -0
  62. package/registry/_docs/pixel-bar.json +69 -0
  63. package/registry/_docs/polar-area.json +81 -0
  64. package/registry/_docs/profile.json +87 -0
  65. package/registry/_docs/progress-steps.json +116 -0
  66. package/registry/_docs/progress.json +152 -0
  67. package/registry/_docs/radio-group.json +112 -0
  68. package/registry/_docs/rating.json +114 -0
  69. package/registry/_docs/ring-carousel.json +122 -0
  70. package/registry/_docs/scratch-card.json +110 -0
  71. package/registry/_docs/search-bar.json +160 -0
  72. package/registry/_docs/search-results.json +123 -0
  73. package/registry/_docs/segmented-control.json +123 -0
  74. package/registry/_docs/select.json +277 -0
  75. package/registry/_docs/settings.json +75 -0
  76. package/registry/_docs/shimmer-button.json +98 -0
  77. package/registry/_docs/sign-in.json +201 -0
  78. package/registry/_docs/skeleton.json +162 -0
  79. package/registry/_docs/slide-to-confirm.json +108 -0
  80. package/registry/_docs/slider.json +177 -0
  81. package/registry/_docs/spin-wheel.json +138 -0
  82. package/registry/_docs/spinner.json +121 -0
  83. package/registry/_docs/stateful-button.json +184 -0
  84. package/registry/_docs/stepper.json +184 -0
  85. package/registry/_docs/swipe-deck.json +118 -0
  86. package/registry/_docs/swipeable-row.json +95 -0
  87. package/registry/_docs/switch.json +124 -0
  88. package/registry/_docs/tabs.json +129 -0
  89. package/registry/_docs/tag-input.json +148 -0
  90. package/registry/_docs/text-animation.json +141 -0
  91. package/registry/_docs/textarea.json +191 -0
  92. package/registry/_docs/ticket-card.json +194 -0
  93. package/registry/_docs/ticket-shape.json +14 -0
  94. package/registry/_docs/tilt-card.json +157 -0
  95. package/registry/_docs/time-picker.json +269 -0
  96. package/registry/_docs/timeline-chart.json +33 -0
  97. package/registry/_docs/timeline.json +172 -0
  98. package/registry/_docs/toast.json +185 -0
  99. package/registry/_docs/treemap.json +33 -0
  100. package/registry/_docs/waterfall.json +39 -0
  101. package/registry/_docs/wheel-list.json +122 -0
  102. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  103. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  104. package/registry/accordion.json +17 -0
  105. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  106. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  107. package/registry/action-sheet.json +18 -0
  108. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  109. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  110. package/registry/alert/files/src/lib/kit.tsx +219 -0
  111. package/registry/alert.json +20 -0
  112. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  113. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  114. package/registry/avatar.json +18 -0
  115. package/registry/badge/files/src/components/ui/badge.tsx +626 -0
  116. package/registry/badge/files/src/lib/kit.tsx +219 -0
  117. package/registry/badge.json +17 -0
  118. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  119. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-nav.json +18 -0
  121. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  122. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  123. package/registry/bottom-sheet.json +17 -0
  124. package/registry/bullet.json +14 -0
  125. package/registry/button/files/src/components/ui/button.tsx +274 -0
  126. package/registry/button/files/src/lib/kit.tsx +219 -0
  127. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  128. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  129. package/registry/button-3d.json +18 -0
  130. package/registry/button.json +18 -0
  131. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  132. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  133. package/registry/calendar.json +17 -0
  134. package/registry/card/files/src/components/ui/card.tsx +300 -0
  135. package/registry/card/files/src/lib/kit.tsx +219 -0
  136. package/registry/card-stack.json +15 -0
  137. package/registry/card.json +17 -0
  138. package/registry/carousel.json +15 -0
  139. package/registry/chat.json +18 -0
  140. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  141. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  142. package/registry/checkbox.json +17 -0
  143. package/registry/checkout.json +26 -0
  144. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  145. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  146. package/registry/chip-group.json +17 -0
  147. package/registry/color-swatch.json +17 -0
  148. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  149. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  150. package/registry/confetti.json +15 -0
  151. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  152. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  153. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  154. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  155. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  156. package/registry/coupon.json +23 -0
  157. package/registry/cube-pager.json +15 -0
  158. package/registry/dashboard.json +32 -0
  159. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  160. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  161. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  162. package/registry/date-picker.json +19 -0
  163. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  164. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  165. package/registry/dialog.json +18 -0
  166. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  167. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  168. package/registry/drawer.json +18 -0
  169. package/registry/fab.json +18 -0
  170. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  171. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  172. package/registry/flip-card.json +15 -0
  173. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  174. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  175. package/registry/floating-input.json +17 -0
  176. package/registry/gift-card.json +21 -0
  177. package/registry/glass-button.json +22 -0
  178. package/registry/glass-nav-bar.json +21 -0
  179. package/registry/glass-search-bar.json +21 -0
  180. package/registry/glass-segmented.json +22 -0
  181. package/registry/glass-sheet.json +22 -0
  182. package/registry/glass-tab-bar.json +22 -0
  183. package/registry/glass.json +19 -0
  184. package/registry/inbox.json +30 -0
  185. package/registry/isometric-bar.json +14 -0
  186. package/registry/kit/files/src/lib/kit.tsx +219 -0
  187. package/registry/kit.json +11 -0
  188. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  189. package/registry/kpi-card.json +14 -0
  190. package/registry/like-button.json +18 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  192. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  193. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  194. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  195. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  196. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  197. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  198. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  199. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  200. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  201. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  202. package/registry/liquidglass-tab.json +23 -0
  203. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  204. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  205. package/registry/list-item.json +17 -0
  206. package/registry/loyalty-card.json +23 -0
  207. package/registry/morph-button.json +18 -0
  208. package/registry/number-ticker.json +15 -0
  209. package/registry/offers.json +24 -0
  210. package/registry/onboarding.json +17 -0
  211. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  212. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  213. package/registry/otp-input.json +17 -0
  214. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  215. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  216. package/registry/pagination-dots.json +15 -0
  217. package/registry/parallax-layers.json +15 -0
  218. package/registry/parallax-scroll.json +17 -0
  219. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  220. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  221. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  222. package/registry/password-input.json +19 -0
  223. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  224. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  225. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  226. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  227. package/registry/payment-card.json +21 -0
  228. package/registry/pixel-bar.json +14 -0
  229. package/registry/polar-area.json +14 -0
  230. package/registry/profile.json +17 -0
  231. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  232. package/registry/progress/files/src/lib/kit.tsx +219 -0
  233. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  234. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  235. package/registry/progress-steps.json +18 -0
  236. package/registry/progress.json +17 -0
  237. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  238. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  239. package/registry/radio-group.json +18 -0
  240. package/registry/rating.json +18 -0
  241. package/registry/ring-carousel.json +15 -0
  242. package/registry/scratch-card.json +18 -0
  243. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  244. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  245. package/registry/search-bar.json +17 -0
  246. package/registry/search-results.json +30 -0
  247. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  248. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  249. package/registry/segmented-control.json +18 -0
  250. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  251. package/registry/select/files/src/lib/kit.tsx +219 -0
  252. package/registry/select.json +17 -0
  253. package/registry/settings.json +26 -0
  254. package/registry/shimmer-button.json +17 -0
  255. package/registry/sign-in.json +26 -0
  256. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  257. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  258. package/registry/skeleton.json +17 -0
  259. package/registry/slide-to-confirm.json +18 -0
  260. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  261. package/registry/slider/files/src/lib/kit.tsx +219 -0
  262. package/registry/slider.json +17 -0
  263. package/registry/spin-wheel.json +18 -0
  264. package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
  265. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  266. package/registry/spinner.json +17 -0
  267. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  268. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  269. package/registry/stateful-button.json +18 -0
  270. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  271. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  272. package/registry/stepper.json +17 -0
  273. package/registry/swipe-deck.json +15 -0
  274. package/registry/swipeable-row.json +15 -0
  275. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  276. package/registry/switch/files/src/lib/kit.tsx +219 -0
  277. package/registry/switch.json +18 -0
  278. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  279. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  280. package/registry/tabs.json +17 -0
  281. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  282. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  283. package/registry/tag-input.json +17 -0
  284. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  285. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  286. package/registry/text-animation.json +15 -0
  287. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  288. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  289. package/registry/textarea.json +17 -0
  290. package/registry/ticket-card.json +24 -0
  291. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  292. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  293. package/registry/ticket-shape.json +17 -0
  294. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  295. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  296. package/registry/tilt-card.json +17 -0
  297. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  298. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  299. package/registry/time-picker.json +17 -0
  300. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  301. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  302. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  303. package/registry/timeline-chart.json +14 -0
  304. package/registry/timeline.json +20 -0
  305. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  306. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  307. package/registry/toast/files/src/lib/kit.tsx +219 -0
  308. package/registry/toast.json +20 -0
  309. package/registry/treemap.json +14 -0
  310. package/registry/waterfall.json +14 -0
  311. package/registry/wheel-list.json +17 -0
  312. package/schema/components.schema.json +13 -0
@@ -0,0 +1,759 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { useEffect, useId, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ ActivityIndicator,
6
+ Animated,
7
+ Easing,
8
+ KeyboardAvoidingView,
9
+ Modal,
10
+ Platform,
11
+ Pressable,
12
+ StyleSheet,
13
+ Text,
14
+ TextInput,
15
+ View,
16
+ type KeyboardTypeOptions,
17
+ type StyleProp,
18
+ type TextInputProps,
19
+ type ViewStyle,
20
+ } from 'react-native';
21
+ import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
22
+
23
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
24
+
25
+ /**
26
+ * `default` — a centred alert dialog.
27
+ * `destructive` — danger-coloured confirm, a warning icon and a warning haptic on open.
28
+ * @deprecated `sheet` — removed; renders as `default`. Use ActionSheet for bottom actions.
29
+ */
30
+ export type DialogVariant = 'default' | 'destructive' | 'sheet';
31
+ /** Living icon shown in a halo above the title. */
32
+ export type DialogTone = 'info' | 'success' | 'warning' | 'danger';
33
+ /** `modern` — pill buttons (default). `ios` — full-width text buttons split by hairlines. */
34
+ export type DialogAppearance = 'modern' | 'ios';
35
+
36
+ export interface DialogAction {
37
+ label: string;
38
+ /**
39
+ * Receives the prompt's text (if any). Return a promise to show a spinner on the button until
40
+ * it settles. Return `false` (or resolve to it) to keep the dialog open and shake it as invalid.
41
+ */
42
+ onPress?: (value: string) => void | boolean | Promise<void | boolean>;
43
+ /** 'primary' is the main call to action; 'destructive' uses the danger colour; 'cancel' is the quiet one. */
44
+ role?: 'primary' | 'default' | 'destructive' | 'cancel';
45
+ disabled?: boolean;
46
+ }
47
+
48
+ /** A single-line text field inside the dialog (a prompt). */
49
+ export interface DialogPrompt {
50
+ defaultValue?: string;
51
+ /** Controlled text. */
52
+ value?: string;
53
+ onChangeText?: (value: string) => void;
54
+ placeholder?: string;
55
+ /** Accessibility label of the field (default: the placeholder or title). */
56
+ label?: string;
57
+ secureTextEntry?: boolean;
58
+ keyboardType?: KeyboardTypeOptions;
59
+ autoCapitalize?: TextInputProps['autoCapitalize'];
60
+ maxLength?: number;
61
+ /** Select the text when the dialog opens (default true). */
62
+ selectTextOnFocus?: boolean;
63
+ }
64
+
65
+ export interface DialogProps {
66
+ open?: boolean;
67
+ defaultOpen?: boolean;
68
+ onOpenChange?: (open: boolean) => void;
69
+ /** `sheet` is deprecated and renders as `default`. */
70
+ variant?: DialogVariant;
71
+ /** Living icon in a halo above the title, plus a soft glow of its colour. Destructive defaults to a warning icon. */
72
+ tone?: DialogTone;
73
+ /** `modern` pill buttons (default) or `ios` hairline-separated text buttons. */
74
+ appearance?: DialogAppearance;
75
+ title?: string;
76
+ description?: string;
77
+ /** Custom content between the description and the actions. */
78
+ children?: React.ReactNode;
79
+ /** Turns the dialog into a prompt with a text field; the text is passed to `onConfirm` / `onPress`. */
80
+ prompt?: DialogPrompt;
81
+ /** Error text under the description / field, in the danger colour. */
82
+ error?: string;
83
+ /** Buttons. Defaults to Cancel + Confirm built from the labels below. */
84
+ actions?: DialogAction[];
85
+ confirmLabel?: string;
86
+ /** Label of the default cancel button; `null` leaves only the confirm button. */
87
+ cancelLabel?: string | null;
88
+ onConfirm?: DialogAction['onPress'];
89
+ /** Called when the default cancel button, the scrim, back or Escape closes the dialog. */
90
+ onCancel?: () => void;
91
+ /** Spinner on the confirm button and every other button disabled (controlled busy state). */
92
+ loading?: boolean;
93
+ /** `auto` puts two short buttons side by side and stacks the rest. */
94
+ actionsLayout?: 'auto' | 'row' | 'stack';
95
+ /** Custom icon inside the halo, or `false` to hide the tone icon. */
96
+ icon?: React.ReactNode | false;
97
+ /** Close when the scrim is tapped, on back and on Escape (default true). */
98
+ dismissible?: boolean;
99
+ /** Notification haptic on open (warning for destructive, success for success) and error on shake (default true). */
100
+ haptics?: boolean;
101
+ /** Colour of the primary action (default: theme primary; accent in `ios`). */
102
+ accent?: string;
103
+ style?: StyleProp<ViewStyle>;
104
+ }
105
+
106
+ const SHAKE = [10, -9, 7, -5, 3, 0];
107
+
108
+ /**
109
+ * A centred alert dialog. The scrim fades in while the card springs from 0.92 to full size; an
110
+ * optional tone icon draws itself inside a halo with a soft glow. Modern pill buttons or iOS
111
+ * hairline buttons, side by side or stacked. Destructive confirms are danger-coloured and buzz a
112
+ * warning on open; async confirms show a spinner; returning `false` shakes the card as invalid.
113
+ * Add `prompt` for a text field. For bottom actions use ActionSheet.
114
+ */
115
+ export function Dialog({
116
+ open,
117
+ defaultOpen = false,
118
+ onOpenChange,
119
+ variant = 'default',
120
+ tone,
121
+ appearance = 'modern',
122
+ title,
123
+ description,
124
+ children,
125
+ prompt,
126
+ error,
127
+ actions,
128
+ confirmLabel,
129
+ cancelLabel = 'Cancel',
130
+ onConfirm,
131
+ onCancel,
132
+ loading = false,
133
+ actionsLayout = 'auto',
134
+ icon,
135
+ dismissible = true,
136
+ haptics = true,
137
+ accent,
138
+ style,
139
+ }: DialogProps) {
140
+ const { colors, radius, dark } = useKit();
141
+ const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
142
+ const reduceMotion = useReduceMotion();
143
+ const destructive = variant === 'destructive';
144
+ const ios = appearance === 'ios';
145
+ const [inner, setInner] = useState(defaultOpen);
146
+ const isOpen = open ?? inner;
147
+ // Stays mounted while the exit animation runs; cleared in its completion callback.
148
+ const keep = useRef(isOpen);
149
+ const [, rerender] = useState(0);
150
+ const mounted = isOpen || keep.current;
151
+ const [busy, setBusy] = useState<number | null>(null);
152
+ // null = untouched since opening, so the field shows `prompt.defaultValue`.
153
+ const [text, setText] = useState<string | null>(null);
154
+ const [focused, setFocused] = useState(false);
155
+ const [box, setBox] = useState({ w: 0, h: 0 });
156
+ const value = prompt?.value ?? text ?? prompt?.defaultValue ?? '';
157
+
158
+ // Native: card life (opacity + scale), scrim, shake, icon glyph pop, halo ripple. JS: icon outline draw.
159
+ const t = useRef(new Animated.Value(0)).current;
160
+ const scrim = useRef(new Animated.Value(0)).current;
161
+ const shake = useRef(new Animated.Value(0)).current;
162
+ const draw = useRef(new Animated.Value(0)).current;
163
+ const glyph = useRef(new Animated.Value(0)).current;
164
+ const ripple = useRef(new Animated.Value(0)).current;
165
+
166
+ const tones: Record<DialogTone, string> = {
167
+ info: dark ? '#60a5fa' : '#2563eb',
168
+ success: colors.success,
169
+ warning: colors.warning,
170
+ danger: colors.danger,
171
+ };
172
+ const glyphKind: DialogTone | null = tone ?? (destructive ? 'warning' : null);
173
+ const toneColor = tone ? tones[tone] : destructive ? colors.danger : null;
174
+ const showHalo = icon !== false && (icon != null || glyphKind !== null);
175
+ const haloColor = toneColor ?? accent ?? colors.accent;
176
+
177
+ const list: DialogAction[] = actions ?? [
178
+ ...(cancelLabel !== null ? [{ label: cancelLabel, role: 'cancel' as const, onPress: () => onCancel?.() }] : []),
179
+ {
180
+ label: confirmLabel ?? (destructive ? 'Delete' : 'Continue'),
181
+ role: destructive ? ('destructive' as const) : ('primary' as const),
182
+ onPress: onConfirm,
183
+ },
184
+ ];
185
+ const confirmIndex = (() => {
186
+ const i = list.findIndex((a) => a.role === 'primary' || a.role === 'destructive');
187
+ return i === -1 ? list.length - 1 : i;
188
+ })();
189
+ const spinning = loading ? confirmIndex : busy;
190
+
191
+ const setOpen = (next: boolean) => {
192
+ if (open === undefined) setInner(next);
193
+ onOpenChange?.(next);
194
+ };
195
+ const requestClose = () => {
196
+ if (!dismissible || spinning !== null) return;
197
+ if (!actions) onCancel?.();
198
+ setOpen(false);
199
+ };
200
+ const live = useRef({ requestClose });
201
+ live.current = { requestClose };
202
+
203
+ const playIcon = () => {
204
+ if (reduceMotion) {
205
+ draw.setValue(1);
206
+ glyph.setValue(1);
207
+ ripple.setValue(1);
208
+ return;
209
+ }
210
+ draw.setValue(0);
211
+ glyph.setValue(0);
212
+ ripple.setValue(0);
213
+ Animated.timing(draw, { toValue: 1, duration: 520, delay: 120, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
214
+ Animated.sequence([Animated.delay(400), Animated.spring(glyph, { toValue: 1, useNativeDriver: true, ...motion.bouncy })]).start();
215
+ Animated.timing(ripple, { toValue: 1, duration: 700, delay: 240, easing: Easing.out(Easing.quad), useNativeDriver: true }).start();
216
+ };
217
+
218
+ useEffect(() => {
219
+ if (isOpen) {
220
+ keep.current = true;
221
+ shake.setValue(0);
222
+ if (haptics) {
223
+ const type =
224
+ destructive || tone === 'warning' || tone === 'danger' || list.some((a) => a.role === 'destructive')
225
+ ? Haptics.NotificationFeedbackType.Warning
226
+ : tone === 'success'
227
+ ? Haptics.NotificationFeedbackType.Success
228
+ : null;
229
+ if (type !== null) Haptics.notificationAsync(type).catch(() => {});
230
+ }
231
+ Animated.parallel([
232
+ Animated.timing(scrim, { toValue: 1, duration: 220, easing: Easing.out(Easing.quad), useNativeDriver: true }),
233
+ reduceMotion
234
+ ? Animated.timing(t, { toValue: 1, duration: 180, useNativeDriver: true })
235
+ : Animated.spring(t, { toValue: 1, useNativeDriver: true, ...motion.snappy }),
236
+ ]).start();
237
+ playIcon();
238
+ } else {
239
+ Animated.parallel([
240
+ Animated.timing(t, { toValue: 0, duration: 150, easing: Easing.in(Easing.quad), useNativeDriver: true }),
241
+ Animated.timing(scrim, { toValue: 0, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }),
242
+ ]).start(({ finished }) => {
243
+ if (!finished) return;
244
+ keep.current = false;
245
+ setText(null);
246
+ setBusy(null);
247
+ rerender((n) => n + 1);
248
+ });
249
+ }
250
+ // eslint-disable-next-line react-hooks/exhaustive-deps
251
+ }, [isOpen]);
252
+
253
+ // Escape closes on web.
254
+ useEffect(() => {
255
+ if (!isOpen || Platform.OS !== 'web' || typeof document === 'undefined') return;
256
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && live.current.requestClose();
257
+ document.addEventListener('keydown', onKey);
258
+ return () => document.removeEventListener('keydown', onKey);
259
+ }, [isOpen]);
260
+
261
+ const reject = () => {
262
+ if (haptics) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
263
+ if (reduceMotion) return;
264
+ shake.setValue(0);
265
+ Animated.sequence(
266
+ SHAKE.map((x) => Animated.timing(shake, { toValue: x, duration: 48, easing: Easing.out(Easing.quad), useNativeDriver: true })),
267
+ ).start();
268
+ };
269
+
270
+ const run = async (a: DialogAction, i: number) => {
271
+ if (spinning !== null || a.disabled) return;
272
+ const result = a.onPress?.(value);
273
+ if (result instanceof Promise) {
274
+ setBusy(i);
275
+ let ok = true;
276
+ try {
277
+ ok = (await result) !== false;
278
+ } catch {
279
+ ok = false;
280
+ }
281
+ setBusy(null);
282
+ if (!ok) return reject();
283
+ } else if (result === false) return reject();
284
+ setOpen(false);
285
+ };
286
+
287
+ const onText = (next: string) => {
288
+ if (prompt?.value === undefined) setText(next);
289
+ prompt?.onChangeText?.(next);
290
+ };
291
+
292
+ // Layout of the buttons. Stacked lists put Cancel last, like iOS.
293
+ const stacked =
294
+ actionsLayout === 'stack' ||
295
+ (actionsLayout === 'auto' && (list.length > 2 || list.some((a) => a.label.length > (ios ? 13 : 15))));
296
+ const order = list.map((a, i) => ({ a, i }));
297
+ if (stacked) order.sort((x, y) => Number(x.a.role === 'cancel') - Number(y.a.role === 'cancel'));
298
+
299
+ const centred = ios || showHalo;
300
+ const edge = alpha(colors.foreground, dark ? 0.1 : 0.06);
301
+ const separator = alpha(colors.foreground, dark ? 0.12 : 0.1);
302
+ const primary = accent ?? (ios ? colors.accent : colors.primary);
303
+ const corner = ios ? radius.scale(16) : radius.xl;
304
+ const haloSize = ios ? 44 : 52;
305
+
306
+ const halo = showHalo ? (
307
+ <View style={[styles.haloWrap, ios && styles.haloWrapIos]}>
308
+ <Halo size={haloSize} corner={radius.capsule} disc={alpha(haloColor, dark ? 0.2 : 0.13)} ring={alpha(haloColor, 0.07)} ripple={ripple}>
309
+ {icon ?? <LivingIcon kind={glyphKind ?? 'info'} color={haloColor} size={ios ? 22 : 26} draw={draw} glyph={glyph} />}
310
+ </Halo>
311
+ </View>
312
+ ) : null;
313
+
314
+ const field = prompt ? (
315
+ <TextInput
316
+ value={value}
317
+ onChangeText={onText}
318
+ placeholder={prompt.placeholder}
319
+ placeholderTextColor={colors.mutedForeground}
320
+ accessibilityLabel={prompt.label ?? prompt.placeholder ?? title}
321
+ secureTextEntry={prompt.secureTextEntry}
322
+ keyboardType={prompt.keyboardType}
323
+ autoCapitalize={prompt.autoCapitalize}
324
+ maxLength={prompt.maxLength}
325
+ selectTextOnFocus={prompt.selectTextOnFocus ?? true}
326
+ autoFocus
327
+ returnKeyType="done"
328
+ onSubmitEditing={() => run(list[confirmIndex], confirmIndex)}
329
+ onFocus={() => setFocused(true)}
330
+ onBlur={() => setFocused(false)}
331
+ selectionColor={accent ?? colors.accent}
332
+ style={[
333
+ ios ? styles.inputIos : styles.input,
334
+ {
335
+ color: colors.foreground,
336
+ backgroundColor: alpha(colors.foreground, dark ? 0.07 : 0.04),
337
+ borderRadius: ios ? radius.scale(9) : radius.scale(14),
338
+ borderColor: error
339
+ ? colors.danger
340
+ : focused && !ios
341
+ ? (accent ?? colors.accent)
342
+ : alpha(colors.foreground, dark ? 0.14 : 0.12),
343
+ },
344
+ ]}
345
+ />
346
+ ) : null;
347
+
348
+ const content = (
349
+ <View style={ios ? styles.contentIos : styles.content}>
350
+ {halo}
351
+ {title ? (
352
+ <Text
353
+ accessibilityRole="header"
354
+ style={[ios ? styles.titleIos : styles.title, { color: colors.foreground }, centred && styles.textCenter]}
355
+ >
356
+ {title}
357
+ </Text>
358
+ ) : null}
359
+ {description ? (
360
+ <Text style={[ios ? styles.descIos : styles.desc, { color: colors.mutedForeground }, centred && styles.textCenter]}>
361
+ {description}
362
+ </Text>
363
+ ) : null}
364
+ {field ? <View style={ios ? styles.fieldIos : styles.field}>{field}</View> : null}
365
+ {error ? (
366
+ <Text accessibilityLiveRegion="polite" style={[styles.error, { color: colors.danger }, centred && styles.textCenter]}>
367
+ {error}
368
+ </Text>
369
+ ) : null}
370
+ {children ? <View style={styles.children}>{children}</View> : null}
371
+ </View>
372
+ );
373
+
374
+ const buttons = ios ? (
375
+ <View style={[styles.iosActions, !stacked && styles.row, { borderTopColor: separator }]}>
376
+ {order.map(({ a, i }, k) => {
377
+ const color = a.role === 'destructive' ? colors.danger : primary;
378
+ return (
379
+ <IosButton
380
+ key={a.label}
381
+ label={a.label}
382
+ color={color}
383
+ highlight={alpha(colors.foreground, dark ? 0.1 : 0.07)}
384
+ loading={spinning === i}
385
+ disabled={a.disabled || (spinning !== null && spinning !== i)}
386
+ onPress={() => run(a, i)}
387
+ style={[
388
+ stacked ? null : styles.flex,
389
+ k > 0 && (stacked ? { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator } : { borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: separator }),
390
+ ]}
391
+ />
392
+ );
393
+ })}
394
+ </View>
395
+ ) : (
396
+ <View style={[styles.actions, !stacked && styles.row]}>
397
+ {order.map(({ a, i }) => {
398
+ const colorsFor =
399
+ a.role === 'destructive'
400
+ ? { bg: colors.danger, fg: colors.dangerForeground }
401
+ : a.role === 'primary'
402
+ ? { bg: primary, fg: readableOn(primary) }
403
+ : a.role === 'cancel'
404
+ ? { bg: alpha(colors.foreground, dark ? 0.1 : 0.06), fg: colors.foreground }
405
+ : { bg: alpha(accent ?? colors.accent, dark ? 0.2 : 0.12), fg: accent ?? colors.accent };
406
+ return (
407
+ <PillButton
408
+ key={a.label}
409
+ label={a.label}
410
+ {...colorsFor}
411
+ corner={radius.capsule(48)}
412
+ loading={spinning === i}
413
+ disabled={a.disabled || (spinning !== null && spinning !== i)}
414
+ onPress={() => run(a, i)}
415
+ style={stacked ? null : styles.flex}
416
+ />
417
+ );
418
+ })}
419
+ </View>
420
+ );
421
+
422
+ const glowR = Math.max(140, box.w * 0.62);
423
+ const glowY = (ios ? 20 : 24) + haloSize / 2;
424
+ const svg =
425
+ box.w > 0 && (toneColor || dark) ? (
426
+ <Svg width={box.w} height={box.h} style={StyleSheet.absoluteFill} pointerEvents="none">
427
+ <Defs>
428
+ <RadialGradient
429
+ id={`glow${uid}`}
430
+ cx={box.w / 2}
431
+ cy={showHalo ? glowY : 0}
432
+ fx={box.w / 2}
433
+ fy={showHalo ? glowY : 0}
434
+ r={glowR}
435
+ gradientUnits="userSpaceOnUse"
436
+ >
437
+ <Stop offset="0" stopColor={toneColor ?? colors.foreground} stopOpacity={toneColor ? (dark ? 0.2 : 0.13) : 0.05} />
438
+ <Stop offset="0.4" stopColor={toneColor ?? colors.foreground} stopOpacity={toneColor ? (dark ? 0.07 : 0.045) : 0.015} />
439
+ <Stop offset="1" stopColor={toneColor ?? colors.foreground} stopOpacity={0} />
440
+ </RadialGradient>
441
+ </Defs>
442
+ <Rect width={box.w} height={box.h} fill={`url(#glow${uid})`} />
443
+ </Svg>
444
+ ) : null;
445
+
446
+ const lifted = {
447
+ opacity: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0, 1, 1], extrapolate: 'clamp' }),
448
+ transform: [
449
+ { translateX: shake },
450
+ { scale: t.interpolate({ inputRange: [0, 1], outputRange: reduceMotion ? [1, 1] : [0.92, 1] }) },
451
+ ],
452
+ };
453
+
454
+ return (
455
+ <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={requestClose}>
456
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: scrim }]}>
457
+ <Pressable style={StyleSheet.absoluteFill} onPress={requestClose} accessible={false} importantForAccessibility="no" />
458
+ </Animated.View>
459
+ <KeyboardAvoidingView
460
+ style={styles.center}
461
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
462
+ pointerEvents="box-none"
463
+ >
464
+ <Animated.View
465
+ accessibilityViewIsModal
466
+ {...(destructive || tone === 'danger' || tone === 'warning' ? { accessibilityRole: 'alert' as const } : null)}
467
+ style={[
468
+ styles.ambient,
469
+ {
470
+ maxWidth: ios ? 300 : 340,
471
+ borderRadius: corner,
472
+ backgroundColor: colors.popover,
473
+ shadowOpacity: dark ? 0.55 : 0.18,
474
+ },
475
+ lifted,
476
+ style,
477
+ ]}
478
+ >
479
+ <View style={[styles.contact, { borderRadius: corner, backgroundColor: colors.popover, shadowOpacity: dark ? 0.3 : 0.06 }]}>
480
+ <View
481
+ onLayout={(e) => {
482
+ const { width: w, height: h } = e.nativeEvent.layout;
483
+ if (Math.round(w) !== box.w || Math.round(h) !== box.h) setBox({ w: Math.round(w), h: Math.round(h) });
484
+ }}
485
+ style={[styles.clip, { borderRadius: corner, borderColor: edge }]}
486
+ >
487
+ {svg}
488
+ {content}
489
+ {buttons}
490
+ </View>
491
+ </View>
492
+ </Animated.View>
493
+ </KeyboardAvoidingView>
494
+ </Modal>
495
+ );
496
+ }
497
+
498
+ function PillButton({
499
+ label,
500
+ bg,
501
+ fg,
502
+ corner,
503
+ loading,
504
+ disabled,
505
+ onPress,
506
+ style,
507
+ }: {
508
+ label: string;
509
+ bg: string;
510
+ fg: string;
511
+ corner: number;
512
+ loading: boolean;
513
+ disabled?: boolean;
514
+ onPress: () => void;
515
+ style?: StyleProp<ViewStyle>;
516
+ }) {
517
+ const press = useRef(new Animated.Value(0)).current;
518
+ const to = (v: number) => Animated.spring(press, { toValue: v, useNativeDriver: true, ...motion.snappy }).start();
519
+ return (
520
+ <Pressable
521
+ onPress={onPress}
522
+ onPressIn={() => to(1)}
523
+ onPressOut={() => to(0)}
524
+ disabled={disabled || loading}
525
+ accessibilityRole="button"
526
+ accessibilityLabel={label}
527
+ accessibilityState={{ disabled: !!disabled, busy: loading }}
528
+ style={style}
529
+ >
530
+ <Animated.View
531
+ style={[
532
+ styles.pill,
533
+ {
534
+ borderRadius: corner,
535
+ backgroundColor: bg,
536
+ opacity: disabled ? 0.45 : press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.86] }),
537
+ transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96] }) }],
538
+ },
539
+ ]}
540
+ >
541
+ <Text numberOfLines={1} style={[styles.pillText, { color: fg, opacity: loading ? 0 : 1 }]}>
542
+ {label}
543
+ </Text>
544
+ {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={fg} /> : null}
545
+ </Animated.View>
546
+ </Pressable>
547
+ );
548
+ }
549
+
550
+ /** Full-width text button whose highlight appears on touch and fades out on release. */
551
+ function IosButton({
552
+ label,
553
+ color,
554
+ highlight,
555
+ loading,
556
+ disabled,
557
+ onPress,
558
+ style,
559
+ }: {
560
+ label: string;
561
+ color: string;
562
+ highlight: string;
563
+ loading: boolean;
564
+ disabled?: boolean;
565
+ onPress: () => void;
566
+ style?: StyleProp<ViewStyle>;
567
+ }) {
568
+ const hl = useRef(new Animated.Value(0)).current;
569
+ return (
570
+ <Pressable
571
+ onPress={onPress}
572
+ onPressIn={() => {
573
+ hl.stopAnimation();
574
+ hl.setValue(1);
575
+ }}
576
+ onPressOut={() => Animated.timing(hl, { toValue: 0, duration: 260, useNativeDriver: true }).start()}
577
+ disabled={disabled || loading}
578
+ accessibilityRole="button"
579
+ accessibilityLabel={label}
580
+ accessibilityState={{ disabled: !!disabled, busy: loading }}
581
+ style={[styles.iosButton, style]}
582
+ >
583
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: highlight, opacity: hl }]} />
584
+ <Text numberOfLines={1} style={[styles.iosText, { color, opacity: loading ? 0 : disabled ? 0.4 : 1 }]}>
585
+ {label}
586
+ </Text>
587
+ {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={color} /> : null}
588
+ </Pressable>
589
+ );
590
+ }
591
+
592
+ /** Tinted disc with a faint outer ring; the ring ripples out once when `ripple` runs 0 → 1. */
593
+ function Halo({
594
+ size,
595
+ corner,
596
+ disc,
597
+ ring,
598
+ ripple,
599
+ children,
600
+ }: {
601
+ size: number;
602
+ corner: (h: number) => number;
603
+ disc: string;
604
+ ring: string;
605
+ ripple: Animated.Value;
606
+ children: React.ReactNode;
607
+ }) {
608
+ const outer = size + 10;
609
+ return (
610
+ <View style={{ width: outer, height: outer, alignItems: 'center', justifyContent: 'center' }}>
611
+ <View style={{ position: 'absolute', width: outer, height: outer, borderRadius: corner(outer), backgroundColor: ring }} />
612
+ <Animated.View
613
+ pointerEvents="none"
614
+ style={{
615
+ position: 'absolute',
616
+ width: size,
617
+ height: size,
618
+ borderRadius: corner(size),
619
+ borderWidth: 1.5,
620
+ borderColor: disc,
621
+ opacity: ripple.interpolate({ inputRange: [0, 0.15, 1], outputRange: [0, 1, 0] }),
622
+ transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 1.8] }) }],
623
+ }}
624
+ />
625
+ <View
626
+ style={{
627
+ width: size,
628
+ height: size,
629
+ borderRadius: corner(size),
630
+ backgroundColor: disc,
631
+ alignItems: 'center',
632
+ justifyContent: 'center',
633
+ }}
634
+ >
635
+ {children}
636
+ </View>
637
+ </View>
638
+ );
639
+ }
640
+
641
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
642
+ const OUTLINE_LEN = 64;
643
+
644
+ function iconPaths(kind: DialogTone) {
645
+ const outline =
646
+ kind === 'warning'
647
+ ? 'M10.3 4.2a2 2 0 0 1 3.4 0l7.1 12.6a2 2 0 0 1-1.7 3H4.9a2 2 0 0 1-1.7-3z'
648
+ : // Circle r=9 drawn from 12 o'clock so it sweeps in clockwise.
649
+ 'M12 3a9 9 0 1 1 0 18a9 9 0 1 1 0-18';
650
+ const mark =
651
+ kind === 'success'
652
+ ? 'M8 12.4l2.7 2.7L16.2 9.4'
653
+ : kind === 'danger'
654
+ ? 'M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6'
655
+ : kind === 'warning'
656
+ ? 'M12 9.5v4'
657
+ : 'M12 11v5';
658
+ const dot = kind === 'warning' ? { cx: 12, cy: 16.6 } : kind === 'info' ? { cx: 12, cy: 7.9 } : null;
659
+ return { outline, mark, dot };
660
+ }
661
+
662
+ /** Outline draws itself (JS-driven dash offset), then the glyph springs in (native scale). */
663
+ function LivingIcon({
664
+ kind,
665
+ color,
666
+ size,
667
+ draw,
668
+ glyph,
669
+ }: {
670
+ kind: DialogTone;
671
+ color: string;
672
+ size: number;
673
+ draw: Animated.Value;
674
+ glyph: Animated.Value;
675
+ }) {
676
+ const { outline, mark, dot } = iconPaths(kind);
677
+ const s = { stroke: color, strokeWidth: 2.1, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
678
+ return (
679
+ <View style={{ width: size, height: size }} pointerEvents="none">
680
+ <Svg width={size} height={size} viewBox="0 0 24 24" style={StyleSheet.absoluteFill}>
681
+ <AnimatedPath
682
+ d={outline}
683
+ {...s}
684
+ strokeDasharray={[OUTLINE_LEN, OUTLINE_LEN]}
685
+ strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [OUTLINE_LEN, 0] })}
686
+ />
687
+ </Svg>
688
+ <Animated.View
689
+ style={[
690
+ StyleSheet.absoluteFill,
691
+ {
692
+ opacity: glyph.interpolate({ inputRange: [0, 0.3, 1], outputRange: [0, 1, 1], extrapolate: 'clamp' }),
693
+ transform: [
694
+ { translateY: glyph.interpolate({ inputRange: [0, 1], outputRange: [-3, 0] }) },
695
+ { scale: glyph.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) },
696
+ ],
697
+ },
698
+ ]}
699
+ >
700
+ <Svg width={size} height={size} viewBox="0 0 24 24">
701
+ <Path d={mark} {...s} />
702
+ {dot ? <Circle cx={dot.cx} cy={dot.cy} r={1.25} fill={color} /> : null}
703
+ </Svg>
704
+ </Animated.View>
705
+ </View>
706
+ );
707
+ }
708
+
709
+ function useReduceMotion() {
710
+ const [reduce, setReduce] = useState(false);
711
+ useEffect(() => {
712
+ let alive = true;
713
+ AccessibilityInfo.isReduceMotionEnabled()
714
+ .then((v) => alive && setReduce(v))
715
+ .catch(() => {});
716
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
717
+ return () => {
718
+ alive = false;
719
+ sub.remove();
720
+ };
721
+ }, []);
722
+ return reduce;
723
+ }
724
+
725
+ const styles = StyleSheet.create({
726
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
727
+ ambient: {
728
+ width: '100%',
729
+ shadowColor: '#000',
730
+ shadowRadius: 32,
731
+ shadowOffset: { width: 0, height: 16 },
732
+ elevation: 18,
733
+ },
734
+ contact: { shadowColor: '#000', shadowRadius: 3, shadowOffset: { width: 0, height: 1 } },
735
+ clip: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
736
+ content: { paddingHorizontal: 22, paddingTop: 24 },
737
+ contentIos: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 18 },
738
+ haloWrap: { alignItems: 'center', marginBottom: 14, marginTop: -5 },
739
+ haloWrapIos: { marginBottom: 10 },
740
+ title: { fontSize: 19, lineHeight: 25, fontWeight: '400', letterSpacing: -0.3 },
741
+ titleIos: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3 },
742
+ desc: { fontSize: 15, lineHeight: 21, fontWeight: '400', marginTop: 6 },
743
+ descIos: { fontSize: 13.5, lineHeight: 18, fontWeight: '400', marginTop: 4 },
744
+ textCenter: { textAlign: 'center' },
745
+ field: { marginTop: 16 },
746
+ fieldIos: { marginTop: 14 },
747
+ input: { height: 48, paddingHorizontal: 14, fontSize: 16, borderWidth: 1 },
748
+ inputIos: { height: 36, paddingHorizontal: 10, fontSize: 14, borderWidth: StyleSheet.hairlineWidth },
749
+ error: { fontSize: 13, lineHeight: 18, fontWeight: '400', marginTop: 8 },
750
+ children: { marginTop: 14 },
751
+ row: { flexDirection: 'row' },
752
+ flex: { flex: 1 },
753
+ actions: { gap: 10, padding: 22, paddingTop: 22 },
754
+ pill: { height: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
755
+ pillText: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.1 },
756
+ iosActions: { borderTopWidth: StyleSheet.hairlineWidth },
757
+ iosButton: { minHeight: 46, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },
758
+ iosText: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3 },
759
+ });