@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,1284 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ Easing,
7
+ PanResponder,
8
+ Pressable,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+ import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
16
+
17
+ import { alpha, motion, readableOn, useKit, type Colors } from '@/lib/kit';
18
+
19
+ // ─── Store ────────────────────────────────────────────────────────────────────
20
+
21
+ export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading';
22
+
23
+ export interface ToastAction {
24
+ label: string;
25
+ onPress: () => void;
26
+ }
27
+
28
+ export interface ToastOptions {
29
+ /** Reuse an id to update a toast in place (e.g. loading → success). */
30
+ id?: string;
31
+ description?: string;
32
+ /** Milliseconds before it auto-dismisses. `Infinity` keeps it until dismissed. */
33
+ duration?: number;
34
+ action?: ToastAction;
35
+ /** Replace the type icon (or `false` for none). */
36
+ icon?: React.ReactNode | false;
37
+ /** Send to the `<Toaster id="…">` with this id. Toasts without one go to the Toaster without an id. */
38
+ toaster?: string;
39
+ }
40
+
41
+ export interface ToastData extends ToastOptions {
42
+ id: string;
43
+ type: ToastType;
44
+ title: string;
45
+ /** Bumped on every update, restarts the timer. */
46
+ updatedAt: number;
47
+ dismissed?: boolean;
48
+ }
49
+
50
+ type Message<T> = string | ((value: T) => string);
51
+
52
+ export type ToastApi = ((title: string, opts?: ToastOptions) => string) & {
53
+ success: (title: string, opts?: ToastOptions) => string;
54
+ error: (title: string, opts?: ToastOptions) => string;
55
+ warning: (title: string, opts?: ToastOptions) => string;
56
+ info: (title: string, opts?: ToastOptions) => string;
57
+ loading: (title: string, opts?: ToastOptions) => string;
58
+ promise: <T>(
59
+ promise: Promise<T> | (() => Promise<T>),
60
+ msgs: { loading: string; success: Message<T>; error: Message<unknown> },
61
+ opts?: ToastOptions,
62
+ ) => Promise<T>;
63
+ dismiss: (id?: string) => void;
64
+ };
65
+
66
+ interface ToastStore {
67
+ subscribe: (listener: () => void) => () => void;
68
+ snapshot: () => ToastData[];
69
+ toast: ToastApi;
70
+ remove: (id: string) => void;
71
+ }
72
+
73
+ function createToastStore(): ToastStore {
74
+ let toasts: ToastData[] = [];
75
+ let counter = 0;
76
+ const listeners = new Set<() => void>();
77
+
78
+ const emit = () => listeners.forEach((l) => l());
79
+ const subscribe = (l: () => void) => {
80
+ listeners.add(l);
81
+ return () => {
82
+ listeners.delete(l);
83
+ };
84
+ };
85
+ const snapshot = () => toasts;
86
+
87
+ function upsert(type: ToastType, title: string, opts: ToastOptions = {}) {
88
+ const id = opts.id ?? `t${++counter}`;
89
+ const next: ToastData = { ...opts, id, type, title, updatedAt: Date.now() + Math.random(), dismissed: false };
90
+ const exists = toasts.some((t) => t.id === id);
91
+ toasts = exists ? toasts.map((t) => (t.id === id ? { ...t, ...next } : t)) : [next, ...toasts];
92
+ emit();
93
+ return id;
94
+ }
95
+
96
+ function remove(id: string) {
97
+ if (!toasts.some((t) => t.id === id)) return;
98
+ toasts = toasts.filter((t) => t.id !== id);
99
+ emit();
100
+ }
101
+
102
+ const toastApi = Object.assign((title: string, opts?: ToastOptions) => upsert('default', title, opts), {
103
+ success: (title: string, opts?: ToastOptions) => upsert('success', title, opts),
104
+ error: (title: string, opts?: ToastOptions) => upsert('error', title, opts),
105
+ warning: (title: string, opts?: ToastOptions) => upsert('warning', title, opts),
106
+ info: (title: string, opts?: ToastOptions) => upsert('info', title, opts),
107
+ loading: (title: string, opts?: ToastOptions) => upsert('loading', title, opts),
108
+ promise<T>(
109
+ promise: Promise<T> | (() => Promise<T>),
110
+ msgs: { loading: string; success: Message<T>; error: Message<unknown> },
111
+ opts?: ToastOptions,
112
+ ): Promise<T> {
113
+ const id = upsert('loading', msgs.loading, opts);
114
+ const p = typeof promise === 'function' ? promise() : promise;
115
+ p.then(
116
+ (v) => upsert('success', typeof msgs.success === 'function' ? msgs.success(v) : msgs.success, { ...opts, id }),
117
+ (e) => upsert('error', typeof msgs.error === 'function' ? msgs.error(e) : msgs.error, { ...opts, id }),
118
+ );
119
+ return p;
120
+ },
121
+ dismiss(id?: string) {
122
+ toasts = toasts.map((t) => (id === undefined || t.id === id ? { ...t, dismissed: true } : t));
123
+ emit();
124
+ },
125
+ }) as ToastApi;
126
+
127
+ return { subscribe, snapshot, toast: toastApi, remove };
128
+ }
129
+
130
+ const globalStore = createToastStore();
131
+
132
+ /**
133
+ * Show a toast from anywhere — no hooks. Render one `<Toaster />` near the root.
134
+ * Prefer `useToast()` inside a `<ToastProvider>` when you need an isolated stack (e.g. demos).
135
+ *
136
+ * toast('Event created')
137
+ * toast.success('Saved', { description: 'All changes synced.' })
138
+ * toast.promise(upload(), { loading: 'Uploading…', success: 'Uploaded', error: 'Upload failed' })
139
+ */
140
+ export const toast = globalStore.toast;
141
+
142
+ const ToastStoreContext = createContext<ToastStore | null>(null);
143
+
144
+ /** Scoped toast queue — nest a `<Toaster />` (and `useToast()`) inside so demos / sheets don't share the global store. */
145
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
146
+ const store = useMemo(() => createToastStore(), []);
147
+ return <ToastStoreContext.Provider value={store}>{children}</ToastStoreContext.Provider>;
148
+ }
149
+
150
+ /** Toast API for the nearest `ToastProvider`, or the global `toast` if none. */
151
+ export function useToast(): ToastApi {
152
+ return useContext(ToastStoreContext)?.toast ?? globalStore.toast;
153
+ }
154
+
155
+ function useToastStore(): ToastStore {
156
+ return useContext(ToastStoreContext) ?? globalStore;
157
+ }
158
+
159
+ // ─── Shared behaviour ─────────────────────────────────────────────────────────
160
+
161
+ function useReduceMotion() {
162
+ const [reduce, setReduce] = useState(false);
163
+ useEffect(() => {
164
+ let alive = true;
165
+ AccessibilityInfo.isReduceMotionEnabled()
166
+ .then((r) => alive && setReduce(r))
167
+ .catch(() => {});
168
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
169
+ return () => {
170
+ alive = false;
171
+ sub.remove();
172
+ };
173
+ }, []);
174
+ return reduce;
175
+ }
176
+
177
+ const HAPTIC: Partial<Record<ToastType, Haptics.NotificationFeedbackType>> = {
178
+ success: Haptics.NotificationFeedbackType.Success,
179
+ warning: Haptics.NotificationFeedbackType.Warning,
180
+ error: Haptics.NotificationFeedbackType.Error,
181
+ };
182
+
183
+ /** One notification tap when a toast appears or changes tone. After a spinner, it lands as the circle closes. */
184
+ function useToneHaptic(id: string | undefined, type: ToastType | undefined, enabled: boolean) {
185
+ const prev = useRef<{ id?: string; type?: ToastType }>({});
186
+ useEffect(() => {
187
+ const from = prev.current;
188
+ prev.current = { id, type };
189
+ if (!enabled || !id || !type || (from.id === id && from.type === type)) return;
190
+ const kind = HAPTIC[type];
191
+ if (kind === undefined) return;
192
+ const t = setTimeout(
193
+ () => Haptics.notificationAsync(kind).catch(() => {}),
194
+ from.id === id && from.type === 'loading' ? 380 : 0,
195
+ );
196
+ return () => clearTimeout(t);
197
+ }, [id, type, enabled]);
198
+ }
199
+
200
+ /**
201
+ * Auto-dismiss clock. Drives a native-driven 1 → 0 `life` value (the drain bar) and fires `onDone`.
202
+ * Both freeze while `paused` and resume where they stopped.
203
+ */
204
+ function useLifetime(key: number | undefined, duration: number, running: boolean, onDone: () => void) {
205
+ const life = useRef(new Animated.Value(1)).current;
206
+ const remaining = useRef(duration);
207
+ const done = useRef(onDone);
208
+ useEffect(() => {
209
+ done.current = onDone;
210
+ });
211
+ useEffect(() => {
212
+ remaining.current = duration;
213
+ life.setValue(1);
214
+ }, [key, duration, life]);
215
+ useEffect(() => {
216
+ if (!running || !Number.isFinite(duration) || duration <= 0) return;
217
+ const startedAt = Date.now();
218
+ life.setValue(Math.max(0, remaining.current / duration));
219
+ Animated.timing(life, {
220
+ toValue: 0,
221
+ duration: Math.max(0, remaining.current),
222
+ easing: Easing.linear,
223
+ useNativeDriver: true,
224
+ }).start();
225
+ const timer = setTimeout(() => done.current(), Math.max(0, remaining.current));
226
+ return () => {
227
+ clearTimeout(timer);
228
+ life.stopAnimation();
229
+ remaining.current -= Date.now() - startedAt;
230
+ life.setValue(Math.max(0, remaining.current / duration));
231
+ };
232
+ }, [key, duration, running, life]);
233
+ return life;
234
+ }
235
+
236
+ // ─── Toaster ──────────────────────────────────────────────────────────────────
237
+
238
+ export type ToasterPosition = 'top' | 'bottom';
239
+ export type ToasterVariant = 'default' | 'rich' | 'minimal' | 'island';
240
+
241
+ export interface ToasterProps {
242
+ /** Only show toasts sent with `{ toaster: id }`. */
243
+ id?: string;
244
+ /** Edge the stack sits on. Ignored by `variant="island"`, which always lives at the top centre. */
245
+ position?: ToasterPosition;
246
+ /**
247
+ * 'default' cards with a tone glow · 'rich' cards filled with the tone · 'minimal' compact inverted pills
248
+ * (a second line for the description when there is one) ·
249
+ * 'island' a black capsule at the top centre that springs open like the iPhone Dynamic Island.
250
+ */
251
+ variant?: ToasterVariant;
252
+ /** Default auto-dismiss time in ms (default 4000). */
253
+ duration?: number;
254
+ /** How many cards peek out of the stack (default 3). */
255
+ visibleToasts?: number;
256
+ /** Always show the stack fanned out instead of collapsed. */
257
+ expand?: boolean;
258
+ /**
259
+ * Distance from the top / bottom edge (default 16, island 10). There is no safe-area dependency:
260
+ * pass `insets.top + 8` (react-native-safe-area-context) to sit the island below a notch.
261
+ */
262
+ offset?: number;
263
+ /** Space between fanned-out cards (default 10). */
264
+ gap?: number;
265
+ /** Show a ✕ on every toast. */
266
+ closeButton?: boolean;
267
+ /** Success / warning / error toasts tap the matching notification haptic (default true). */
268
+ haptics?: boolean;
269
+ style?: StyleProp<ViewStyle>;
270
+ }
271
+
272
+ const PEEK = 12;
273
+ const GUESS_HEIGHT = 64;
274
+
275
+ /**
276
+ * Hosts the toasts. It fills its parent absolutely (touches pass through), so drop it at the
277
+ * root of your app — or inside any container to scope it there. Toasts stack like cards, fan out
278
+ * when tapped, swipe away sideways or toward the edge, and pause their timers (and the draining
279
+ * time line) while you touch them. `variant="island"` turns it into a Dynamic Island.
280
+ */
281
+ export function Toaster({
282
+ id,
283
+ position = 'bottom',
284
+ variant = 'default',
285
+ duration = 4000,
286
+ visibleToasts = 3,
287
+ expand = false,
288
+ offset,
289
+ gap = 10,
290
+ closeButton = false,
291
+ haptics = true,
292
+ style,
293
+ }: ToasterProps) {
294
+ const store = useToastStore();
295
+ const all = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
296
+ // Inside ToastProvider, show the whole scoped stack (id filter is for the global store only).
297
+ const mine = store === globalStore ? all.filter((t) => t.toaster === id) : all;
298
+ const reduceMotion = useReduceMotion();
299
+
300
+ const active = mine.filter((t) => !t.dismissed);
301
+ const [heights, setHeights] = useState<Record<string, number>>({});
302
+ const [open, setOpen] = useState(false);
303
+ const expanded = expand || (open && active.length > 1);
304
+ const s = position === 'bottom' ? -1 : 1; // direction the stack grows in
305
+
306
+ useEffect(() => {
307
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- collapse once the stack is down to one card
308
+ if (active.length <= 1 && open) setOpen(false);
309
+ }, [active.length, open]);
310
+
311
+ if (variant === 'island') {
312
+ return (
313
+ <IslandToaster
314
+ store={store}
315
+ toasts={mine}
316
+ duration={duration}
317
+ offset={offset ?? 10}
318
+ haptics={haptics}
319
+ reduceMotion={reduceMotion}
320
+ style={style}
321
+ />
322
+ );
323
+ }
324
+
325
+ const frontH = heights[active[0]?.id] ?? GUESS_HEIGHT;
326
+ const layout: Record<string, StackLayout> = {};
327
+ let acc = 0;
328
+ active.forEach((t, i) => {
329
+ const h = heights[t.id] ?? GUESS_HEIGHT;
330
+ if (expanded) {
331
+ layout[t.id] = { y: s * acc, scale: 1, hidden: i >= visibleToasts, faded: false };
332
+ } else {
333
+ const clamped = Math.min(h, frontH);
334
+ layout[t.id] = {
335
+ // Line up the far edges, then let each older card peek out a little further.
336
+ y: s * (frontH - clamped + PEEK * i),
337
+ scale: 1 - 0.05 * i,
338
+ hidden: i >= visibleToasts,
339
+ clamp: i > 0 ? frontH : undefined,
340
+ faded: i > 0,
341
+ };
342
+ }
343
+ acc += h + gap;
344
+ });
345
+
346
+ return (
347
+ <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
348
+ {[...mine].reverse().map((t) => (
349
+ <ToastItem
350
+ key={t.id}
351
+ data={t}
352
+ layout={layout[t.id]}
353
+ edge={position}
354
+ edgeOffset={offset ?? 16}
355
+ variant={variant}
356
+ duration={t.duration ?? (t.type === 'loading' ? Infinity : duration)}
357
+ paused={expanded}
358
+ closeButton={closeButton}
359
+ haptics={haptics}
360
+ reduceMotion={reduceMotion}
361
+ onHeight={(h) => setHeights((prev) => (prev[t.id] === h ? prev : { ...prev, [t.id]: h }))}
362
+ onPress={() => active.length > 1 && !expand && setOpen((o) => !o)}
363
+ onRemoved={() => {
364
+ store.remove(t.id);
365
+ setHeights(({ [t.id]: _, ...rest }) => rest);
366
+ }}
367
+ />
368
+ ))}
369
+ </View>
370
+ );
371
+ }
372
+
373
+ // ─── Toast card ───────────────────────────────────────────────────────────────
374
+
375
+ interface StackLayout {
376
+ y: number;
377
+ scale: number;
378
+ hidden: boolean;
379
+ clamp?: number;
380
+ faded: boolean;
381
+ }
382
+
383
+ interface ToastItemProps {
384
+ data: ToastData;
385
+ /** Undefined while the toast is leaving. */
386
+ layout?: StackLayout;
387
+ edge: ToasterPosition;
388
+ edgeOffset: number;
389
+ variant: Exclude<ToasterVariant, 'island'>;
390
+ duration: number;
391
+ paused: boolean;
392
+ closeButton: boolean;
393
+ haptics: boolean;
394
+ reduceMotion: boolean;
395
+ onHeight: (h: number) => void;
396
+ onPress: () => void;
397
+ onRemoved: () => void;
398
+ }
399
+
400
+ function rubber(d: number) {
401
+ return (d * 40) / (40 + d);
402
+ }
403
+
404
+ function ToastItem({
405
+ data,
406
+ layout,
407
+ edge,
408
+ edgeOffset,
409
+ variant,
410
+ duration,
411
+ paused,
412
+ closeButton,
413
+ haptics,
414
+ reduceMotion,
415
+ onHeight,
416
+ onPress,
417
+ onRemoved,
418
+ }: ToastItemProps) {
419
+ const toast = useToast();
420
+ const { colors, radius, dark } = useKit();
421
+ const toward = edge === 'bottom' ? 1 : -1; // swipe direction that dismisses toward the edge
422
+ const last = useRef(layout ?? { y: 0, scale: 1, hidden: false, faded: false });
423
+ if (layout) last.current = layout;
424
+ const L = last.current;
425
+
426
+ // Every value here is native-driven: they all end up in transform / opacity.
427
+ const enter = useRef(new Animated.Value(0)).current;
428
+ const y = useRef(new Animated.Value(L.y)).current;
429
+ const scale = useRef(new Animated.Value(L.scale)).current;
430
+ const vis = useRef(new Animated.Value(L.hidden ? 0 : 1)).current;
431
+ const content = useRef(new Animated.Value(L.faded ? 0 : 1)).current;
432
+ const dragX = useRef(new Animated.Value(0)).current;
433
+ const dragY = useRef(new Animated.Value(0)).current;
434
+ const [touching, setTouching] = useState(false);
435
+ const gone = useRef(false);
436
+
437
+ useToneHaptic(data.id, data.type, haptics && !data.dismissed);
438
+
439
+ useEffect(() => {
440
+ Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
441
+ }, [enter]);
442
+
443
+ const target = layout ?? L;
444
+ useEffect(() => {
445
+ if (!layout || data.dismissed) return;
446
+ Animated.parallel([
447
+ Animated.spring(y, { toValue: target.y, useNativeDriver: true, ...motion.snappy }),
448
+ Animated.spring(scale, { toValue: target.scale, useNativeDriver: true, ...motion.snappy }),
449
+ Animated.timing(vis, { toValue: target.hidden ? 0 : 1, duration: 200, useNativeDriver: true }),
450
+ Animated.timing(content, { toValue: target.faded ? 0 : 1, duration: 200, useNativeDriver: true }),
451
+ ]).start();
452
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on the numbers, not the object identity
453
+ }, [target.y, target.scale, target.hidden, target.faded, data.dismissed]);
454
+
455
+ // Leave: fade + slide toward the edge, then drop out of the store.
456
+ useEffect(() => {
457
+ if (!data.dismissed || gone.current) return;
458
+ gone.current = true;
459
+ Animated.timing(enter, { toValue: 0, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(onRemoved);
460
+ // eslint-disable-next-line react-hooks/exhaustive-deps
461
+ }, [data.dismissed]);
462
+
463
+ // Auto-dismiss timer + drain line; both pause while the stack is open or a finger is down.
464
+ const life = useLifetime(data.updatedAt, duration, !data.dismissed && !paused && !touching, () => toast.dismiss(data.id));
465
+
466
+ const flyOut = (axis: 'x' | 'y', to: number) => {
467
+ gone.current = true;
468
+ Animated.parallel([
469
+ Animated.timing(axis === 'x' ? dragX : dragY, {
470
+ toValue: to,
471
+ duration: 180,
472
+ easing: Easing.out(Easing.quad),
473
+ useNativeDriver: true,
474
+ }),
475
+ Animated.timing(enter, { toValue: 0, duration: 180, useNativeDriver: true }),
476
+ ]).start(onRemoved);
477
+ };
478
+
479
+ const pan = useRef(
480
+ (() => {
481
+ const gesture = { axis: null as 'x' | 'y' | null };
482
+ return PanResponder.create({
483
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6,
484
+ onPanResponderGrant: () => {
485
+ gesture.axis = null;
486
+ setTouching(true);
487
+ },
488
+ onPanResponderMove: (_, g) => {
489
+ if (!gesture.axis) gesture.axis = Math.abs(g.dx) > Math.abs(g.dy) ? 'x' : 'y';
490
+ if (gesture.axis === 'x') dragX.setValue(g.dx);
491
+ else {
492
+ const d = g.dy * toward;
493
+ dragY.setValue(d >= 0 ? g.dy : -toward * rubber(-d));
494
+ }
495
+ },
496
+ onPanResponderRelease: (_, g) => {
497
+ setTouching(false);
498
+ const { axis } = gesture;
499
+ if (axis === 'x' && (Math.abs(g.dx) > 90 || Math.abs(g.vx) > 0.7)) return flyOut('x', Math.sign(g.dx || g.vx) * 520);
500
+ if (axis === 'y' && (g.dy * toward > 36 || g.vy * toward > 0.6)) return flyOut('y', toward * 140);
501
+ Animated.parallel([
502
+ Animated.spring(dragX, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
503
+ Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }),
504
+ ]).start();
505
+ },
506
+ onPanResponderTerminate: () => {
507
+ setTouching(false);
508
+ Animated.spring(dragX, { toValue: 0, useNativeDriver: true }).start();
509
+ Animated.spring(dragY, { toValue: 0, useNativeDriver: true }).start();
510
+ },
511
+ });
512
+ })(),
513
+ ).current;
514
+
515
+ const look = theme(variant, data.type, colors, dark);
516
+ const minimal = variant === 'minimal';
517
+ // A one-line pill stays a capsule; with a description it becomes a soft rounded card.
518
+ const r = minimal ? (data.description ? radius.scale(20) : radius.capsule(44)) : radius.lg;
519
+
520
+ const opacity = Animated.multiply(
521
+ Animated.multiply(enter, vis),
522
+ dragX.interpolate({ inputRange: [-260, 0, 260], outputRange: [0, 1, 0], extrapolate: 'clamp' }),
523
+ );
524
+ const translateY = Animated.add(
525
+ Animated.add(y, dragY),
526
+ enter.interpolate({ inputRange: [0, 1], outputRange: [toward * 28, 0] }),
527
+ );
528
+ const s = Animated.multiply(scale, enter.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }));
529
+
530
+ const typed = data.type !== 'default';
531
+ const icon =
532
+ data.icon === false ? null : (data.icon ??
533
+ (typed ? <LivingIcon type={data.type} color={look.icon} halo={!minimal} reduceMotion={reduceMotion} /> : null));
534
+ const showGlow = !minimal && typed && data.icon !== false && look.glow > 0;
535
+ const showBar = !minimal && Number.isFinite(duration);
536
+
537
+ return (
538
+ <Animated.View
539
+ pointerEvents="box-none"
540
+ style={[
541
+ styles.lane,
542
+ edge === 'bottom' ? { bottom: edgeOffset } : { top: edgeOffset },
543
+ {
544
+ opacity,
545
+ transform: [{ translateY }, { translateX: dragX }, { scale: s }],
546
+ transformOrigin: edge === 'bottom' ? 'center top' : 'center bottom',
547
+ },
548
+ ]}
549
+ >
550
+ {/* Shadow layer (unclipped) */}
551
+ <View
552
+ {...pan.panHandlers}
553
+ style={[
554
+ minimal ? styles.cardMinimal : styles.cardFull,
555
+ { borderRadius: r, backgroundColor: look.bg, maxHeight: L.clamp, boxShadow: shadowFor(dark, minimal) },
556
+ ]}
557
+ >
558
+ {/* Surface (clips the glow and drain line to the corners) */}
559
+ <View style={[styles.clip, { borderRadius: r, borderColor: look.border, maxHeight: L.clamp }]}>
560
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: content }]}>
561
+ {showGlow ? <Glow color={look.glowColor} strength={look.glow} x={32} y={32} /> : null}
562
+ </Animated.View>
563
+ <Pressable
564
+ onPress={onPress}
565
+ onPressIn={() => setTouching(true)}
566
+ onPressOut={() => setTouching(false)}
567
+ accessibilityRole="alert"
568
+ accessibilityLiveRegion="polite"
569
+ accessibilityLabel={[data.title, data.description].filter(Boolean).join('. ')}
570
+ onLayout={(e) => onHeight(Math.round(e.nativeEvent.layout.height))}
571
+ >
572
+ <Animated.View style={[styles.row, minimal ? styles.padMinimal : icon ? styles.padIcon : styles.pad, { opacity: content }]}>
573
+ {icon ? <View style={!minimal && data.description ? styles.iconTop : null}>{icon}</View> : null}
574
+ <View style={minimal ? styles.textMinimal : styles.text}>
575
+ <Text numberOfLines={minimal ? 1 : 3} style={[styles.title, { color: look.title }]}>
576
+ {data.title}
577
+ </Text>
578
+ {data.description ? (
579
+ <Text numberOfLines={minimal ? 2 : 4} style={[styles.desc, minimal && styles.descMinimal, { color: look.desc }]}>
580
+ {data.description}
581
+ </Text>
582
+ ) : null}
583
+ </View>
584
+ {data.action ? (
585
+ <Pressable
586
+ accessibilityRole="button"
587
+ hitSlop={8}
588
+ onPress={() => {
589
+ data.action?.onPress();
590
+ toast.dismiss(data.id);
591
+ }}
592
+ style={({ pressed }) => [
593
+ styles.action,
594
+ { borderRadius: radius.capsule(32), backgroundColor: look.actionBg, opacity: pressed ? 0.7 : 1 },
595
+ ]}
596
+ >
597
+ <Text style={[styles.actionText, { color: look.actionFg }]}>{data.action.label}</Text>
598
+ </Pressable>
599
+ ) : null}
600
+ {closeButton ? (
601
+ <Pressable
602
+ accessibilityRole="button"
603
+ accessibilityLabel="Dismiss"
604
+ hitSlop={12}
605
+ onPress={() => toast.dismiss(data.id)}
606
+ style={styles.close}
607
+ >
608
+ <Svg width={14} height={14} viewBox="0 0 24 24">
609
+ <Path d="M6 6l12 12M18 6L6 18" stroke={look.desc} strokeWidth={2.4} strokeLinecap="round" />
610
+ </Svg>
611
+ </Pressable>
612
+ ) : null}
613
+ </Animated.View>
614
+ </Pressable>
615
+ {showBar ? (
616
+ <Animated.View
617
+ pointerEvents="none"
618
+ style={[styles.bar, { backgroundColor: look.bar, opacity: content, transform: [{ scaleX: life }] }]}
619
+ />
620
+ ) : null}
621
+ </View>
622
+ </View>
623
+ </Animated.View>
624
+ );
625
+ }
626
+
627
+ // ─── Island ───────────────────────────────────────────────────────────────────
628
+
629
+ const CAP_W = 124;
630
+ const CAP_H = 36;
631
+ type Stage = 'hidden' | 'capsule' | 'open';
632
+
633
+ interface IslandToasterProps {
634
+ store: ToastStore;
635
+ toasts: ToastData[];
636
+ duration: number;
637
+ offset: number;
638
+ haptics: boolean;
639
+ reduceMotion: boolean;
640
+ style?: StyleProp<ViewStyle>;
641
+ }
642
+
643
+ /**
644
+ * Dynamic-Island host: one black capsule at the top centre. It widens, then grows taller into a card,
645
+ * holds, and folds back into the capsule. A newer toast squashes the island and swaps its content in place.
646
+ *
647
+ * Drivers: `w` / `h` (size, corner) are JS-driven on `frame` + `clip`; presence, squash, drag and content
648
+ * fade are native-driven on their own wrapper views.
649
+ */
650
+ function IslandToaster({ store, toasts, duration, offset, haptics, reduceMotion, style }: IslandToasterProps) {
651
+ const { colors, radius, dark } = useKit();
652
+ const active = toasts.filter((t) => !t.dismissed);
653
+ const front = active[0];
654
+
655
+ const [stage, setStageState] = useState<Stage>('hidden');
656
+ const stageRef = useRef<Stage>('hidden');
657
+ const [shownId, setShownIdState] = useState<string | null>(null);
658
+ const shownRef = useRef<string | null>(null);
659
+ const lastShown = useRef<ToastData | null>(null);
660
+ const [laneW, setLaneW] = useState(0);
661
+ const [contentH, setContentH] = useState(0);
662
+ const [expanded, setExpanded] = useState(false);
663
+ const [touching, setTouching] = useState(false);
664
+
665
+ const live = shownId ? toasts.find((t) => t.id === shownId) : undefined;
666
+ useEffect(() => {
667
+ if (live) lastShown.current = live;
668
+ });
669
+ const shown = live ?? (shownId ? lastShown.current : null);
670
+
671
+ // JS-driven (layout)
672
+ const w = useRef(new Animated.Value(CAP_W)).current;
673
+ const h = useRef(new Animated.Value(CAP_H)).current;
674
+ // Native-driven (transform / opacity)
675
+ const presence = useRef(new Animated.Value(0)).current;
676
+ const sqX = useRef(new Animated.Value(1)).current;
677
+ const sqY = useRef(new Animated.Value(1)).current;
678
+ const dragY = useRef(new Animated.Value(0)).current;
679
+ const content = useRef(new Animated.Value(0)).current;
680
+
681
+ const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
682
+ const later = (ms: number, fn: () => void) => {
683
+ timers.current.push(setTimeout(fn, ms));
684
+ };
685
+ const clearLater = () => {
686
+ timers.current.forEach(clearTimeout);
687
+ timers.current = [];
688
+ };
689
+ useEffect(() => () => timers.current.forEach(clearTimeout), []);
690
+
691
+ const setStage = (s: Stage) => {
692
+ stageRef.current = s;
693
+ setStageState(s);
694
+ };
695
+ const setShown = (id: string | null) => {
696
+ shownRef.current = id;
697
+ setShownIdState(id);
698
+ };
699
+
700
+ const squash = () => {
701
+ if (reduceMotion) return;
702
+ Animated.sequence([
703
+ Animated.parallel([
704
+ Animated.timing(sqX, { toValue: 1.05, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
705
+ Animated.timing(sqY, { toValue: 0.9, duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true }),
706
+ ]),
707
+ Animated.parallel([
708
+ Animated.spring(sqX, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
709
+ Animated.spring(sqY, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
710
+ ]),
711
+ ]).start();
712
+ };
713
+
714
+ // Lifecycle: react to the front toast changing.
715
+ useEffect(() => {
716
+ const current = shownRef.current;
717
+ if (front) {
718
+ if (!current || stageRef.current === 'hidden') {
719
+ clearLater();
720
+ dragY.setValue(0);
721
+ setShown(front.id);
722
+ setExpanded(false);
723
+ setStage('capsule');
724
+ later(reduceMotion ? 0 : 140, () => setStage('open'));
725
+ } else if (front.id !== current) {
726
+ clearLater();
727
+ // Replace in place: older toasts are superseded.
728
+ toasts.forEach((t) => t.id !== front.id && store.remove(t.id));
729
+ if (stageRef.current === 'open') {
730
+ Animated.timing(content, { toValue: 0, duration: 90, useNativeDriver: true }).start(() => {
731
+ setShown(front.id);
732
+ setExpanded(false);
733
+ squash();
734
+ Animated.timing(content, { toValue: 1, duration: 200, delay: 30, useNativeDriver: true }).start();
735
+ });
736
+ } else {
737
+ setShown(front.id);
738
+ setExpanded(false);
739
+ setStage('open');
740
+ }
741
+ } else if (stageRef.current !== 'open') {
742
+ clearLater();
743
+ setStage('open');
744
+ }
745
+ } else if (current && stageRef.current !== 'hidden') {
746
+ // Fold back into the capsule, then vanish.
747
+ clearLater();
748
+ setExpanded(false);
749
+ setStage('capsule');
750
+ later(reduceMotion ? 120 : 360, () => setStage('hidden'));
751
+ later(reduceMotion ? 300 : 620, () => {
752
+ setShown(null);
753
+ store.remove(current);
754
+ });
755
+ }
756
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- driven by the front toast only
757
+ }, [front?.id]);
758
+
759
+ // Same toast, new tone (loading → success): a small squash as the icon morphs.
760
+ const shownType = shown?.type;
761
+ const prevType = useRef(shownType);
762
+ useEffect(() => {
763
+ if (prevType.current && shownType && prevType.current !== shownType && stageRef.current === 'open') squash();
764
+ prevType.current = shownType;
765
+ // eslint-disable-next-line react-hooks/exhaustive-deps
766
+ }, [shownType]);
767
+
768
+ // Declarative size / presence per stage.
769
+ const openW = laneW ? Math.min(laneW - 24, 380) : CAP_W;
770
+ useEffect(() => {
771
+ const isOpen = stage === 'open';
772
+ const tw = isOpen ? openW : CAP_W;
773
+ const th = isOpen ? Math.max(CAP_H, contentH || 64) : CAP_H;
774
+ const wA = Animated.spring(w, { toValue: tw, useNativeDriver: false, ...(isOpen ? motion.bouncy : motion.snappy) });
775
+ const hA = Animated.spring(h, { toValue: th, useNativeDriver: false, ...motion.snappy });
776
+ Animated.spring(presence, { toValue: stage === 'hidden' ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
777
+ if (isOpen) {
778
+ Animated.stagger(reduceMotion ? 0 : 90, [wA, hA]).start();
779
+ Animated.timing(content, { toValue: 1, duration: 220, delay: reduceMotion ? 0 : 160, useNativeDriver: true }).start();
780
+ } else {
781
+ Animated.timing(content, { toValue: 0, duration: 110, useNativeDriver: true }).start();
782
+ Animated.stagger(reduceMotion ? 0 : 70, [hA, wA]).start();
783
+ }
784
+ }, [stage, openW, contentH, reduceMotion, w, h, presence, content]);
785
+
786
+ useToneHaptic(shown?.id, shown?.type, haptics && stage === 'open' && !shown?.dismissed);
787
+
788
+ const dur = shown ? (shown.duration ?? (shown.type === 'loading' ? Infinity : duration)) : Infinity;
789
+ const life = useLifetime(
790
+ shown ? shown.updatedAt : undefined,
791
+ dur,
792
+ !!shown && !shown.dismissed && stage === 'open' && !touching && !expanded,
793
+ () => shownRef.current && store.toast.dismiss(shownRef.current),
794
+ );
795
+
796
+ // Swipe up to dismiss.
797
+ const pan = useRef(
798
+ PanResponder.create({
799
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 6 && Math.abs(g.dy) > Math.abs(g.dx),
800
+ onPanResponderGrant: () => setTouching(true),
801
+ onPanResponderMove: (_, g) => dragY.setValue(g.dy < 0 ? g.dy : rubber(g.dy)),
802
+ onPanResponderRelease: (_, g) => {
803
+ setTouching(false);
804
+ const id = shownRef.current;
805
+ if (id && (g.dy < -28 || g.vy < -0.5)) {
806
+ clearLater();
807
+ Animated.parallel([
808
+ Animated.timing(dragY, { toValue: -90, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: true }),
809
+ Animated.timing(presence, { toValue: 0, duration: 160, useNativeDriver: true }),
810
+ Animated.timing(content, { toValue: 0, duration: 100, useNativeDriver: true }),
811
+ ]).start(() => {
812
+ w.setValue(CAP_W);
813
+ h.setValue(CAP_H);
814
+ dragY.setValue(0);
815
+ setExpanded(false);
816
+ setStage('hidden');
817
+ setShown(null);
818
+ store.remove(id);
819
+ });
820
+ return;
821
+ }
822
+ Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
823
+ },
824
+ onPanResponderTerminate: () => {
825
+ setTouching(false);
826
+ Animated.spring(dragY, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
827
+ },
828
+ }),
829
+ ).current;
830
+
831
+ const bg = dark ? mix('#000000', colors.foreground, 0.05) : '#000000';
832
+ const fg = readableOn(bg);
833
+ const tone = !shown || shown.type === 'default' || shown.type === 'loading' ? mix(fg, bg, 0.25) : toneOf(shown.type, colors, true);
834
+ const capR = radius.capsule(CAP_H);
835
+ const cardR = Math.max(capR, radius.scale(30));
836
+ const corner = h.interpolate({ inputRange: [CAP_H, CAP_H + 28], outputRange: [capR, cardR], extrapolate: 'clamp' });
837
+ const left = Animated.multiply(Animated.subtract(w, openW), 0.5);
838
+ const pScale = presence.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] });
839
+ const typed = !!shown && shown.type !== 'default';
840
+ const icon =
841
+ !shown || shown.icon === false
842
+ ? null
843
+ : (shown.icon ?? (typed ? <LivingIcon type={shown.type} color={tone} reduceMotion={reduceMotion} /> : null));
844
+ const showBar = Number.isFinite(dur);
845
+
846
+ return (
847
+ <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.host, style]}>
848
+ <View
849
+ pointerEvents="box-none"
850
+ style={[styles.islandLane, { top: offset }]}
851
+ onLayout={(e) => setLaneW(Math.round(e.nativeEvent.layout.width))}
852
+ >
853
+ {shown ? (
854
+ <Animated.View
855
+ {...pan.panHandlers}
856
+ style={{
857
+ opacity: presence,
858
+ transform: [{ translateY: dragY }, { scaleX: Animated.multiply(pScale, sqX) }, { scaleY: Animated.multiply(pScale, sqY) }],
859
+ transformOrigin: 'center top',
860
+ }}
861
+ >
862
+ <Animated.View
863
+ style={{
864
+ width: w,
865
+ height: h,
866
+ borderRadius: corner,
867
+ backgroundColor: bg,
868
+ boxShadow: dark
869
+ ? '0 0 0 1px rgba(255,255,255,0.08), 0 10px 30px -6px rgba(0,0,0,0.6)'
870
+ : '0 2px 6px rgba(0,0,0,0.12), 0 14px 36px -8px rgba(0,0,0,0.35)',
871
+ }}
872
+ >
873
+ <Animated.View style={[styles.islandClip, { borderRadius: corner }]}>
874
+ <Animated.View style={[styles.islandPlace, { width: openW, left }]}>
875
+ <Animated.View
876
+ style={{
877
+ opacity: content,
878
+ transform: [{ scale: content.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }) }],
879
+ }}
880
+ onLayout={(e) => setContentH(Math.round(e.nativeEvent.layout.height))}
881
+ >
882
+ {typed && shown.icon !== false ? <Glow color={tone} strength={0.3} x={33} y={32} /> : null}
883
+ <Pressable
884
+ onPress={() => setExpanded((x) => !x)}
885
+ onPressIn={() => setTouching(true)}
886
+ onPressOut={() => setTouching(false)}
887
+ accessibilityRole="alert"
888
+ accessibilityLiveRegion="polite"
889
+ accessibilityLabel={[shown.title, shown.description].filter(Boolean).join('. ')}
890
+ accessibilityHint={shown.description || shown.action ? (expanded ? 'Collapses the notification' : 'Expands the notification') : undefined}
891
+ accessibilityState={{ expanded }}
892
+ style={[styles.row, icon ? styles.islandPadIcon : styles.islandPad]}
893
+ >
894
+ {icon ? <View style={expanded && shown.description ? styles.iconTop : null}>{icon}</View> : null}
895
+ <View style={styles.text}>
896
+ <Text numberOfLines={expanded ? 3 : 1} style={[styles.title, { color: fg }]}>
897
+ {shown.title}
898
+ </Text>
899
+ {shown.description ? (
900
+ <Text numberOfLines={expanded ? undefined : 1} style={[styles.desc, { color: alpha(fg, 0.62) }]}>
901
+ {shown.description}
902
+ </Text>
903
+ ) : null}
904
+ </View>
905
+ {!expanded && shown.action ? (
906
+ <View style={[styles.islandChip, { borderRadius: radius.capsule(24), backgroundColor: alpha(fg, 0.14) }]}>
907
+ <Text style={[styles.islandChipText, { color: fg }]}>{shown.action.label}</Text>
908
+ </View>
909
+ ) : null}
910
+ </Pressable>
911
+ {expanded && shown.action ? (
912
+ <View style={styles.islandActions}>
913
+ <Pressable
914
+ accessibilityRole="button"
915
+ onPress={() => {
916
+ shown.action?.onPress();
917
+ store.toast.dismiss(shown.id);
918
+ }}
919
+ style={({ pressed }) => [
920
+ styles.islandAction,
921
+ { borderRadius: radius.capsule(44), backgroundColor: alpha(fg, pressed ? 0.24 : 0.16) },
922
+ ]}
923
+ >
924
+ <Text style={[styles.title, { color: fg }]}>{shown.action.label}</Text>
925
+ </Pressable>
926
+ </View>
927
+ ) : null}
928
+ {showBar ? (
929
+ <View style={[styles.islandTrack, { backgroundColor: alpha(fg, 0.1) }]}>
930
+ <Animated.View
931
+ style={[styles.islandFill, { backgroundColor: typed ? tone : alpha(fg, 0.5), transform: [{ scaleX: life }] }]}
932
+ />
933
+ </View>
934
+ ) : null}
935
+ </Animated.View>
936
+ </Animated.View>
937
+ </Animated.View>
938
+ </Animated.View>
939
+ </Animated.View>
940
+ ) : null}
941
+ </View>
942
+ </View>
943
+ );
944
+ }
945
+
946
+ // ─── Look ─────────────────────────────────────────────────────────────────────
947
+
948
+ function hex6(h: string) {
949
+ return h.length === 4 ? '#' + [...h.slice(1)].map((c) => c + c).join('') : h;
950
+ }
951
+
952
+ function rgb(h: string) {
953
+ const n = parseInt(hex6(h).slice(1, 7), 16);
954
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
955
+ }
956
+
957
+ function toHex(v: number[]) {
958
+ return '#' + v.map((x) => Math.round(Math.max(0, Math.min(255, x))).toString(16).padStart(2, '0')).join('');
959
+ }
960
+
961
+ /** Opaque blend of two #rrggbb colours (stacked cards must not see through each other). */
962
+ function mix(a: string, b: string, t: number) {
963
+ if (!a.startsWith('#') || !b.startsWith('#')) return a;
964
+ const [x, y] = [rgb(a), rgb(b)];
965
+ return toHex(x.map((v, i) => v + (y[i] - v) * t));
966
+ }
967
+
968
+ function toneOf(type: ToastType, c: Colors, dark: boolean) {
969
+ const tones: Record<ToastType, string> = {
970
+ default: c.foreground,
971
+ loading: c.mutedForeground,
972
+ success: c.success,
973
+ error: c.danger,
974
+ warning: c.warning,
975
+ info: dark ? '#60a5fa' : '#2563eb',
976
+ };
977
+ return tones[type];
978
+ }
979
+
980
+ function shadowFor(dark: boolean, minimal: boolean) {
981
+ if (minimal) return dark ? '0 6px 20px -4px rgba(0,0,0,0.6)' : '0 6px 20px -6px rgba(0,0,0,0.3)';
982
+ return dark
983
+ ? '0 1px 2px rgba(0,0,0,0.5), 0 8px 20px -6px rgba(0,0,0,0.55), 0 24px 48px -16px rgba(0,0,0,0.6)'
984
+ : '0 1px 2px rgba(0,0,0,0.05), 0 6px 14px -4px rgba(0,0,0,0.08), 0 22px 44px -14px rgba(0,0,0,0.18)';
985
+ }
986
+
987
+ interface Look {
988
+ bg: string;
989
+ border: string;
990
+ title: string;
991
+ desc: string;
992
+ icon: string;
993
+ glowColor: string;
994
+ glow: number;
995
+ bar: string;
996
+ actionBg: string;
997
+ actionFg: string;
998
+ }
999
+
1000
+ function theme(variant: Exclude<ToasterVariant, 'island'>, type: ToastType, c: Colors, dark: boolean): Look {
1001
+ const tone = toneOf(type, c, dark);
1002
+ if (variant === 'minimal') {
1003
+ return {
1004
+ bg: c.foreground,
1005
+ border: c.foreground,
1006
+ title: c.background,
1007
+ desc: alpha(c.background, 0.7),
1008
+ icon: type === 'default' || type === 'loading' ? c.background : tone,
1009
+ glowColor: tone,
1010
+ glow: 0,
1011
+ bar: 'transparent',
1012
+ actionBg: alpha(c.background, 0.16),
1013
+ actionFg: c.background,
1014
+ };
1015
+ }
1016
+ if (variant === 'rich' && type !== 'default' && type !== 'loading') {
1017
+ // Filled: the solid tone with readable text on it; no second hue and no light-leak.
1018
+ const fg = readableOn(tone);
1019
+ return {
1020
+ bg: tone,
1021
+ border: alpha(fg, 0.12),
1022
+ title: fg,
1023
+ desc: alpha(fg, 0.78),
1024
+ icon: fg,
1025
+ glowColor: tone,
1026
+ glow: 0,
1027
+ bar: alpha(fg, 0.5),
1028
+ actionBg: alpha(fg, 0.18),
1029
+ actionFg: fg,
1030
+ };
1031
+ }
1032
+ return {
1033
+ bg: c.popover,
1034
+ border: alpha(c.foreground, dark ? 0.1 : 0.08),
1035
+ title: c.foreground,
1036
+ desc: c.mutedForeground,
1037
+ icon: tone,
1038
+ glowColor: tone,
1039
+ glow: dark ? 0.18 : 0.12,
1040
+ bar: type === 'default' || type === 'loading' ? alpha(c.foreground, 0.22) : alpha(tone, 0.6),
1041
+ actionBg: c.primary,
1042
+ actionFg: c.primaryForeground,
1043
+ };
1044
+ }
1045
+
1046
+ /** Light leak: a radial tone glow centred on (x, y) that spills across the top-left of the surface. */
1047
+ function Glow({ color, strength, x, y }: { color: string; strength: number; x: number; y: number }) {
1048
+ const id = 'gl' + useId().replace(/[^a-zA-Z0-9]/g, '');
1049
+ const W = 320;
1050
+ const H = 200;
1051
+ return (
1052
+ <Svg pointerEvents="none" width={W} height={H} style={{ position: 'absolute', left: x - W / 2, top: y - H / 2 }}>
1053
+ <Defs>
1054
+ <RadialGradient id={id} cx="50%" cy="50%" rx="50%" ry="50%">
1055
+ <Stop offset="0" stopColor={color} stopOpacity={strength} />
1056
+ <Stop offset="0.35" stopColor={color} stopOpacity={strength * 0.45} />
1057
+ <Stop offset="1" stopColor={color} stopOpacity={0} />
1058
+ </RadialGradient>
1059
+ </Defs>
1060
+ <Rect width={W} height={H} fill={`url(#${id})`} />
1061
+ </Svg>
1062
+ );
1063
+ }
1064
+
1065
+ // ─── Living icon ──────────────────────────────────────────────────────────────
1066
+
1067
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
1068
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
1069
+
1070
+ const ICON = 20;
1071
+ const HALO = 30;
1072
+ const HALO_OUT = 38;
1073
+ const RING = 2 * Math.PI * 9; // r = 9
1074
+ const TRIANGLE = 57; // perimeter of the rounded warning triangle
1075
+ const ARC = 0.28; // spinner arc as a fraction of the ring
1076
+
1077
+ const TRIANGLE_D = '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';
1078
+
1079
+ /**
1080
+ * Tone icon that draws itself: the outline strokes in, then the glyph draws and springs. A loading
1081
+ * spinner's arc keeps turning while it closes into the success / error circle, then the mark draws in.
1082
+ */
1083
+ function LivingIcon({
1084
+ type,
1085
+ color,
1086
+ halo = true,
1087
+ reduceMotion = false,
1088
+ }: {
1089
+ type: ToastType;
1090
+ color: string;
1091
+ halo?: boolean;
1092
+ reduceMotion?: boolean;
1093
+ }) {
1094
+ const { radius } = useKit();
1095
+ // JS-driven SVG props
1096
+ const outline = useRef(new Animated.Value(0)).current;
1097
+ const draw = useRef(new Animated.Value(0)).current;
1098
+ // Native-driven transforms / opacity (separate views)
1099
+ const pop = useRef(new Animated.Value(0.5)).current;
1100
+ const spin = useRef(new Animated.Value(0)).current;
1101
+ const ripple = useRef(new Animated.Value(1)).current;
1102
+ const loopRef = useRef<Animated.CompositeAnimation | null>(null);
1103
+ const prev = useRef<ToastType | null>(null);
1104
+
1105
+ useEffect(() => {
1106
+ const from = prev.current;
1107
+ prev.current = type;
1108
+ const stopSpin = () => {
1109
+ loopRef.current?.stop();
1110
+ loopRef.current = null;
1111
+ };
1112
+
1113
+ if (type === 'loading') {
1114
+ if (!loopRef.current) {
1115
+ spin.setValue(0);
1116
+ loopRef.current = Animated.loop(
1117
+ Animated.timing(spin, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }),
1118
+ );
1119
+ loopRef.current.start();
1120
+ }
1121
+ draw.setValue(0);
1122
+ if (reduceMotion) outline.setValue(ARC);
1123
+ else Animated.timing(outline, { toValue: ARC, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
1124
+ return;
1125
+ }
1126
+
1127
+ if (reduceMotion) {
1128
+ stopSpin();
1129
+ outline.setValue(1);
1130
+ draw.setValue(1);
1131
+ pop.setValue(1);
1132
+ return;
1133
+ }
1134
+
1135
+ const morph = from === 'loading';
1136
+ if (!morph) outline.setValue(0);
1137
+ draw.setValue(0);
1138
+ pop.setValue(0.5);
1139
+ // The arc keeps spinning while it closes; a full ring looks the same at any angle, so it stops invisibly.
1140
+ Animated.timing(outline, {
1141
+ toValue: 1,
1142
+ duration: morph ? 380 : 420,
1143
+ easing: morph ? Easing.inOut(Easing.cubic) : Easing.out(Easing.cubic),
1144
+ useNativeDriver: false,
1145
+ }).start(({ finished }) => {
1146
+ if (!finished) return;
1147
+ stopSpin();
1148
+ ripple.setValue(0);
1149
+ Animated.parallel([
1150
+ Animated.timing(draw, { toValue: 1, duration: 280, easing: Easing.out(Easing.cubic), useNativeDriver: false }),
1151
+ Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
1152
+ Animated.timing(ripple, { toValue: 1, duration: 700, easing: Easing.out(Easing.quad), useNativeDriver: true }),
1153
+ ]).start();
1154
+ });
1155
+ }, [type, reduceMotion, outline, draw, pop, spin, ripple]);
1156
+
1157
+ useEffect(() => () => loopRef.current?.stop(), []);
1158
+
1159
+ const stroke = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
1160
+ const dash = (len: number, from = 0, to = 1) => ({
1161
+ strokeDasharray: [len, len],
1162
+ strokeDashoffset: draw.interpolate({ inputRange: [from, to], outputRange: [len, 0], extrapolate: 'clamp' }),
1163
+ });
1164
+ const dot = draw.interpolate({ inputRange: [0, 0.6, 1], outputRange: [0, 0, 1] });
1165
+ const triangle = type === 'warning';
1166
+ const per = triangle ? TRIANGLE : RING;
1167
+ const box = halo ? HALO_OUT : ICON;
1168
+ const inset = (box - ICON) / 2;
1169
+ const circle = (d: number) => ({ width: d, height: d, borderRadius: radius.full, left: (box - d) / 2, top: (box - d) / 2 });
1170
+
1171
+ return (
1172
+ <View style={{ width: box, height: box }} importantForAccessibility="no-hide-descendants">
1173
+ {halo ? (
1174
+ <>
1175
+ <View style={[styles.abs, circle(HALO_OUT), { backgroundColor: alpha(color, 0.08) }]} />
1176
+ <View style={[styles.abs, circle(HALO), { backgroundColor: alpha(color, 0.14) }]} />
1177
+ <Animated.View
1178
+ style={[
1179
+ styles.abs,
1180
+ circle(HALO_OUT),
1181
+ {
1182
+ borderWidth: 1.5,
1183
+ borderColor: alpha(color, 0.35),
1184
+ opacity: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
1185
+ transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.55] }) }],
1186
+ },
1187
+ ]}
1188
+ />
1189
+ </>
1190
+ ) : null}
1191
+ <Animated.View
1192
+ style={[
1193
+ styles.abs,
1194
+ { left: inset, top: inset },
1195
+ { transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] },
1196
+ ]}
1197
+ >
1198
+ <Svg width={ICON} height={ICON} viewBox="0 0 24 24">
1199
+ {triangle ? (
1200
+ <AnimatedPath
1201
+ d={TRIANGLE_D}
1202
+ {...stroke}
1203
+ strokeDasharray={[per, per]}
1204
+ strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
1205
+ />
1206
+ ) : (
1207
+ <>
1208
+ {type === 'loading' ? <Circle cx={12} cy={12} r={9} {...stroke} stroke={alpha(color, 0.2)} /> : null}
1209
+ <AnimatedCircle
1210
+ cx={12}
1211
+ cy={12}
1212
+ r={9}
1213
+ {...stroke}
1214
+ rotation={-90}
1215
+ origin="12, 12"
1216
+ strokeDasharray={[per, per]}
1217
+ strokeDashoffset={outline.interpolate({ inputRange: [0, 1], outputRange: [per, 0] })}
1218
+ />
1219
+ </>
1220
+ )}
1221
+ </Svg>
1222
+ </Animated.View>
1223
+ {type !== 'loading' ? (
1224
+ <Animated.View style={[styles.abs, { left: inset, top: inset, transform: [{ scale: pop }] }]}>
1225
+ <Svg width={ICON} height={ICON} viewBox="0 0 24 24">
1226
+ {type === 'success' ? <AnimatedPath d="M8 12.4l2.7 2.7L16.2 9.4" {...stroke} {...dash(12)} /> : null}
1227
+ {type === 'error' ? (
1228
+ <>
1229
+ <AnimatedPath d="M9.2 9.2l5.6 5.6" {...stroke} {...dash(8, 0, 0.55)} />
1230
+ <AnimatedPath d="M14.8 9.2l-5.6 5.6" {...stroke} {...dash(8, 0.45, 1)} />
1231
+ </>
1232
+ ) : null}
1233
+ {type === 'warning' ? (
1234
+ <>
1235
+ <AnimatedPath d="M12 9.5v4" {...stroke} {...dash(4.2)} />
1236
+ <AnimatedCircle cx={12} cy={16.6} r={1.25} fill={color} opacity={dot} />
1237
+ </>
1238
+ ) : null}
1239
+ {type === 'info' ? (
1240
+ <>
1241
+ <AnimatedCircle cx={12} cy={7.9} r={1.25} fill={color} opacity={dot} />
1242
+ <AnimatedPath d="M12 11v5" {...stroke} {...dash(5.2)} />
1243
+ </>
1244
+ ) : null}
1245
+ </Svg>
1246
+ </Animated.View>
1247
+ ) : null}
1248
+ </View>
1249
+ );
1250
+ }
1251
+
1252
+ const styles = StyleSheet.create({
1253
+ host: { zIndex: 1000, elevation: 1000 },
1254
+ lane: { position: 'absolute', left: 0, right: 0, alignItems: 'center', paddingHorizontal: 16 },
1255
+ cardFull: { width: '100%', maxWidth: 420 },
1256
+ cardMinimal: { maxWidth: 360 },
1257
+ clip: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },
1258
+ row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
1259
+ pad: { paddingVertical: 14, paddingHorizontal: 16 },
1260
+ padIcon: { paddingVertical: 12, paddingLeft: 12, paddingRight: 16 },
1261
+ padMinimal: { paddingVertical: 10, paddingLeft: 14, paddingRight: 16, gap: 10 },
1262
+ iconTop: { alignSelf: 'flex-start' },
1263
+ text: { flex: 1, gap: 2 },
1264
+ textMinimal: { flexShrink: 1 },
1265
+ title: { fontSize: 14.5, fontWeight: '400', lineHeight: 20, letterSpacing: -0.1 },
1266
+ desc: { fontSize: 13.5, fontWeight: '400', lineHeight: 19 },
1267
+ descMinimal: { fontSize: 13, lineHeight: 17 },
1268
+ action: { paddingHorizontal: 12, height: 32, alignItems: 'center', justifyContent: 'center' },
1269
+ actionText: { fontSize: 13, fontWeight: '400' },
1270
+ close: { alignSelf: 'flex-start', padding: 2, marginRight: -4 },
1271
+ bar: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2, transformOrigin: 'left' },
1272
+ abs: { position: 'absolute' },
1273
+ islandLane: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
1274
+ islandClip: { flex: 1, overflow: 'hidden' },
1275
+ islandPlace: { position: 'absolute', top: 0 },
1276
+ islandPad: { paddingVertical: 14, paddingHorizontal: 20 },
1277
+ islandPadIcon: { paddingTop: 13, paddingBottom: 15, paddingLeft: 14, paddingRight: 18 },
1278
+ islandChip: { paddingHorizontal: 10, height: 24, justifyContent: 'center' },
1279
+ islandChipText: { fontSize: 12.5, fontWeight: '400' },
1280
+ islandActions: { paddingHorizontal: 14, paddingBottom: 16, marginTop: -2 },
1281
+ islandAction: { height: 44, alignItems: 'center', justifyContent: 'center' },
1282
+ islandTrack: { position: 'absolute', left: 28, right: 28, bottom: 6, height: 2, borderRadius: 1, overflow: 'hidden' },
1283
+ islandFill: { flex: 1, transformOrigin: 'left' },
1284
+ });