@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,790 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { useEffect, useId, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ Easing,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import Svg, { Circle, Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
15
+
16
+ import { alpha, motion, useKit } from '@/lib/kit';
17
+
18
+ export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
19
+ export type AvatarShape = 'circle' | 'rounded';
20
+ export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';
21
+ /** 'story' / 'accent' solid accent ring · 'seen' (same as `ring="story"` + `seen`). */
22
+ export type AvatarRing = 'none' | 'story' | 'accent' | 'seen';
23
+ /** 'soft' tint with deep ink (default) · 'solid' flat fill · 'gradient' opt-in two-tone. */
24
+ export type AvatarFallback = 'soft' | 'solid' | 'gradient';
25
+
26
+ export interface AvatarProps {
27
+ /** Image URL. Initials show while it loads, and stay if it fails. */
28
+ src?: string;
29
+ /** Used for the initials, the fallback colour (same name → same colour) and the accessibility label. */
30
+ name?: string;
31
+ /** 'xs' 24 · 'sm' 32 · 'md' 40 · 'lg' 56 · 'xl' 72, or any number of points. */
32
+ size?: AvatarSize | number;
33
+ shape?: AvatarShape;
34
+ /** Presence dot cut into the bottom-right edge. */
35
+ status?: AvatarStatus;
36
+ /** Soft pulse behind the status dot (default: on for 'online'). */
37
+ pulse?: boolean;
38
+ /** Ring around the face, separated by a clear gap. */
39
+ ring?: AvatarRing;
40
+ /** The story has been watched: the ring cross-fades to a quiet hairline. */
41
+ seen?: boolean;
42
+ /** Ring colour override — the first entry is used as a solid colour (default: `accent`). */
43
+ ringColors?: string[];
44
+ /** Spin a broken story ring — a "story is loading" state. */
45
+ ringSpin?: boolean;
46
+ /** Initials look when there is no photo. Default 'soft'. */
47
+ fallback?: AvatarFallback;
48
+ /** Soft layered shadow under the face (default true for 72pt and up). */
49
+ elevated?: boolean;
50
+ /** Notification count (`true` = dot), top-right. */
51
+ badge?: number | boolean;
52
+ /** Verified seal with a check, bottom-right (bottom-left when a status dot is shown). */
53
+ verified?: boolean;
54
+ /** Colour behind the avatar — the dot, badge and seal cut-outs are punched in it. Default: theme background. */
55
+ surface?: string;
56
+ /** Ring and verified seal colour (default: theme accent). */
57
+ accent?: string;
58
+ /** Makes it a button with a spring press and a light haptic tick. */
59
+ onPress?: () => void;
60
+ /** Haptic tick on press (default true). */
61
+ haptics?: boolean;
62
+ style?: StyleProp<ViewStyle>;
63
+ }
64
+
65
+ const SIZES: Record<AvatarSize, number> = { xs: 24, sm: 32, md: 40, lg: 56, xl: 72 };
66
+
67
+ /** Stable 0–359 hue for a name (FNV-1a hash). */
68
+ export function hueFromName(name = '') {
69
+ let h = 0x811c9dc5;
70
+ const s = name.trim().toLowerCase();
71
+ for (let i = 0; i < s.length; i++) {
72
+ h ^= s.charCodeAt(i);
73
+ h = Math.imul(h, 0x01000193);
74
+ }
75
+ return (h >>> 0) % 360;
76
+ }
77
+
78
+ /**
79
+ * Fallback colours for a name: `base` for flat and soft fills, `ink` for the initials — white when
80
+ * it reads, otherwise a deep shade of the same hue — and `from` → `to` for the opt-in gradient look.
81
+ */
82
+ export function gradientFromName(name = '', dark = false) {
83
+ const h = hueFromName(name);
84
+ // Warm hues turn toward red (not lime); greens and cyans are perceptually bright, so sit a touch deeper.
85
+ const turn = h >= 15 && h < 95 ? -28 : 28;
86
+ const deep = h >= 70 && h < 205 ? 0.09 : 0;
87
+ const from = hsl(h - turn * 0.3, 0.74, (dark ? 0.58 : 0.64) - deep);
88
+ const to = hsl(h + turn, 0.68, (dark ? 0.4 : 0.44) - deep);
89
+ const base = hsl(h + turn * 0.35, 0.7, (dark ? 0.5 : 0.53) - deep);
90
+ const ink = contrast('#ffffff', base) >= 3 ? '#ffffff' : hsl(h + turn * 0.35, 0.75, 0.16);
91
+ return { from, to, base, ink, hue: h };
92
+ }
93
+
94
+ /** Same name, same colour — every time, on every device. */
95
+ export function colorFromName(name = '') {
96
+ return gradientFromName(name).base;
97
+ }
98
+
99
+ export function initialsFrom(name = '', max = 2) {
100
+ const parts = name.trim().split(/\s+/).filter(Boolean);
101
+ if (!parts.length) return '';
102
+ const letters = parts.length === 1 ? [parts[0][0]] : [parts[0][0], parts[parts.length - 1][0]];
103
+ return letters.slice(0, max).join('').toUpperCase();
104
+ }
105
+
106
+ const resolveSize = (s: AvatarSize | number) => (typeof s === 'number' ? s : SIZES[s]);
107
+
108
+ /**
109
+ * Photo that fades in over soft tinted initials (hue hashed from the name), with
110
+ * presence dots, notification badges and a verified seal cut into the edge, a gapped story ring
111
+ * that cross-fades to "seen", and an optional spring press.
112
+ */
113
+ export function Avatar({
114
+ src,
115
+ name,
116
+ size = 'md',
117
+ shape = 'circle',
118
+ status,
119
+ pulse,
120
+ ring = 'none',
121
+ seen,
122
+ ringColors,
123
+ ringSpin,
124
+ fallback = 'soft',
125
+ elevated,
126
+ badge,
127
+ verified,
128
+ surface,
129
+ accent,
130
+ onPress,
131
+ haptics = true,
132
+ style,
133
+ }: AvatarProps) {
134
+ const { colors, radius, dark } = useKit();
135
+ const reduceMotion = useReduceMotion();
136
+ const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
137
+ const px = resolveSize(size);
138
+ const bg = surface ?? colors.background;
139
+ const tint = accent ?? colors.accent;
140
+ const corner = shape === 'circle' ? px / 2 : Math.min(px / 2, radius.scale(Math.round(px * 0.3)));
141
+ const faceRadius = shape === 'circle' ? radius.full : corner;
142
+
143
+ // Remember which URL failed so a new `src` gets a fresh try without resetting state in an effect.
144
+ const [failedSrc, setFailedSrc] = useState<string>();
145
+ const failed = !!src && failedSrc === src;
146
+ const loaded = useRef(new Animated.Value(0)).current;
147
+ const press = useRef(new Animated.Value(1)).current;
148
+ const spin = useRef(new Animated.Value(0)).current;
149
+ const pulseV = useRef(new Animated.Value(0)).current;
150
+
151
+ const isSeen = seen ?? ring === 'seen';
152
+ const seenV = useRef(new Animated.Value(isSeen ? 1 : 0)).current;
153
+
154
+ useEffect(() => {
155
+ loaded.setValue(0);
156
+ }, [src, loaded]);
157
+
158
+ useEffect(() => {
159
+ Animated.timing(seenV, {
160
+ toValue: isSeen ? 1 : 0,
161
+ duration: reduceMotion ? 0 : 320,
162
+ easing: Easing.out(Easing.cubic),
163
+ useNativeDriver: true,
164
+ }).start();
165
+ }, [isSeen, reduceMotion, seenV]);
166
+
167
+ const showPulse = !!status && !reduceMotion && (pulse ?? status === 'online');
168
+ useEffect(() => {
169
+ if (!showPulse) return;
170
+ pulseV.setValue(0);
171
+ const loop = Animated.loop(
172
+ Animated.sequence([
173
+ Animated.timing(pulseV, { toValue: 1, duration: 1500, easing: Easing.out(Easing.quad), useNativeDriver: true }),
174
+ Animated.delay(700),
175
+ ]),
176
+ );
177
+ loop.start();
178
+ return () => loop.stop();
179
+ }, [showPulse, pulseV]);
180
+
181
+ const hasRing = ring !== 'none' || seen === true;
182
+ const spinning = !!ringSpin && hasRing && shape === 'circle' && !isSeen && !reduceMotion;
183
+ useEffect(() => {
184
+ if (!spinning) return;
185
+ spin.setValue(0);
186
+ const loop = Animated.loop(
187
+ Animated.timing(spin, { toValue: 1, duration: 2400, easing: Easing.linear, useNativeDriver: true }),
188
+ );
189
+ loop.start();
190
+ return () => loop.stop();
191
+ }, [spinning, spin]);
192
+
193
+ // Geometry: ring stroke + clear gap around the face; everything else is laid out inside `outer`.
194
+ const stroke = hasRing ? Math.max(2, Math.round(px * 0.05)) : 0;
195
+ const gap = hasRing ? Math.max(2, Math.round(px * 0.045)) : 0;
196
+ const pad = stroke + gap;
197
+ const outer = px + pad * 2;
198
+ const circ = Math.PI * (outer - stroke);
199
+ const ringPaint = ringColors?.[0] ?? tint;
200
+ const hairline = alpha(colors.foreground, dark ? 0.22 : 0.16);
201
+
202
+ // Points on the face edge at 45°: works for circles and rounded squares alike.
203
+ const r = Math.min(corner, px / 2);
204
+ const near = r * (1 - Math.SQRT1_2);
205
+ const far = px - near;
206
+ const cut = Math.max(1.5, Math.round(px * 0.045 * 2) / 2);
207
+
208
+ const named = !!name?.trim();
209
+ const g = gradientFromName(name, dark);
210
+ const initials = initialsFrom(name, px < 30 ? 1 : 2);
211
+ const look = named ? fallback : 'none';
212
+ const faceBg =
213
+ look === 'soft' ? alpha(g.base, dark ? 0.24 : 0.16) : look === 'none' ? colors.muted : g.base;
214
+ const ink =
215
+ look === 'soft'
216
+ ? hsl(g.hue, dark ? 0.8 : 0.6, dark ? 0.76 : 0.34)
217
+ : look === 'none'
218
+ ? colors.mutedForeground
219
+ : g.ink;
220
+ const showElevated = elevated ?? px >= 72;
221
+ const showPhoto = !!src && !failed;
222
+
223
+ const label =
224
+ [
225
+ name,
226
+ verified ? 'verified' : null,
227
+ status,
228
+ typeof badge === 'number' && badge > 0 ? `${badge} notifications` : badge === true ? 'new notification' : null,
229
+ hasRing ? (isSeen ? 'story seen' : 'new story') : null,
230
+ ]
231
+ .filter(Boolean)
232
+ .join(', ') || 'Avatar';
233
+
234
+ const onImageLoad = () => {
235
+ Animated.timing(loaded, {
236
+ toValue: 1,
237
+ duration: reduceMotion ? 120 : 320,
238
+ easing: Easing.out(Easing.cubic),
239
+ useNativeDriver: true,
240
+ }).start();
241
+ };
242
+
243
+ const face = (
244
+ <View
245
+ style={[
246
+ styles.face,
247
+ { width: px, height: px, borderRadius: faceRadius, backgroundColor: faceBg },
248
+ showElevated
249
+ ? {
250
+ shadowColor: '#000',
251
+ shadowOpacity: dark ? 0.5 : 0.14,
252
+ shadowRadius: px * 0.16,
253
+ shadowOffset: { width: 0, height: Math.max(2, px * 0.07) },
254
+ elevation: Math.round(px * 0.1),
255
+ }
256
+ : null,
257
+ ]}
258
+ >
259
+ <View style={[styles.clip, { borderRadius: faceRadius }]}>
260
+ {look === 'gradient' ? (
261
+ <Svg width={px} height={px} style={StyleSheet.absoluteFill}>
262
+ <Defs>
263
+ <LinearGradient id={`fb${uid}`} x1="0" y1="0" x2="1" y2="1">
264
+ <Stop offset="0" stopColor={g.from} />
265
+ <Stop offset="1" stopColor={g.to} />
266
+ </LinearGradient>
267
+ <LinearGradient id={`hl${uid}`} x1="0" y1="0" x2="0" y2="1">
268
+ <Stop offset="0" stopColor="#ffffff" stopOpacity={dark ? 0.12 : 0.2} />
269
+ <Stop offset="0.55" stopColor="#ffffff" stopOpacity={0} />
270
+ </LinearGradient>
271
+ </Defs>
272
+ <Rect width={px} height={px} fill={`url(#fb${uid})`} />
273
+ <Rect width={px} height={px} fill={`url(#hl${uid})`} />
274
+ </Svg>
275
+ ) : null}
276
+
277
+ {initials ? (
278
+ <Text
279
+ allowFontScaling={false}
280
+ numberOfLines={1}
281
+ style={[
282
+ styles.initials,
283
+ {
284
+ color: ink,
285
+ fontSize: Math.max(9, px * (initials.length > 1 ? 0.37 : 0.42)),
286
+ letterSpacing: px >= 56 ? 0.4 : 0,
287
+ },
288
+ ink === '#ffffff' && look !== 'soft' ? styles.inkShadow : null,
289
+ ]}
290
+ >
291
+ {initials}
292
+ </Text>
293
+ ) : (
294
+ <PersonIcon size={px} color={ink} />
295
+ )}
296
+
297
+ {showPhoto ? (
298
+ <Animated.Image
299
+ source={{ uri: src }}
300
+ onLoad={onImageLoad}
301
+ onError={() => setFailedSrc(src)}
302
+ accessibilityIgnoresInvertColors
303
+ style={[
304
+ StyleSheet.absoluteFill,
305
+ {
306
+ opacity: loaded,
307
+ transform: [{ scale: loaded.interpolate({ inputRange: [0, 1], outputRange: [1.06, 1] }) }],
308
+ },
309
+ ]}
310
+ />
311
+ ) : null}
312
+
313
+ {/* Inner hairline so photos and gradients separate from any background. */}
314
+ <View
315
+ pointerEvents="none"
316
+ style={[
317
+ StyleSheet.absoluteFill,
318
+ {
319
+ borderRadius: faceRadius,
320
+ borderWidth: StyleSheet.hairlineWidth * 2,
321
+ borderColor: alpha(colors.foreground, dark ? 0.1 : 0.06),
322
+ },
323
+ ]}
324
+ />
325
+ </View>
326
+ </View>
327
+ );
328
+
329
+ const ringLayer = (paint: string, width: number, dashed: boolean) =>
330
+ shape === 'circle' ? (
331
+ <Circle
332
+ cx={outer / 2}
333
+ cy={outer / 2}
334
+ r={(outer - stroke) / 2}
335
+ stroke={paint}
336
+ strokeWidth={width}
337
+ fill="none"
338
+ strokeLinecap="round"
339
+ strokeDasharray={dashed ? [circ * 0.7, circ * 0.3] : undefined}
340
+ />
341
+ ) : (
342
+ <Rect
343
+ x={stroke / 2}
344
+ y={stroke / 2}
345
+ width={outer - stroke}
346
+ height={outer - stroke}
347
+ rx={corner + gap + stroke / 2}
348
+ stroke={paint}
349
+ strokeWidth={width}
350
+ fill="none"
351
+ />
352
+ );
353
+
354
+ const statusCorner = status ? 'br' : null;
355
+ const sealCorner = verified ? (statusCorner ? 'bl' : 'br') : null;
356
+ const dot = Math.max(8, Math.round(px * 0.24));
357
+ const seal = Math.max(12, Math.round(px * 0.3));
358
+ const badgeH = Math.max(14, Math.round(px * 0.34));
359
+ const badgeText = typeof badge === 'number' ? (badge > 99 ? '99+' : String(badge)) : null;
360
+ const showBadge = badge === true || (typeof badge === 'number' && badge > 0);
361
+
362
+ /** Absolute position for a cut-out of diameter `d` centred on the face edge at a corner. */
363
+ const at = (c: 'br' | 'bl' | 'tr', d: number): ViewStyle => ({
364
+ position: 'absolute',
365
+ left: pad + (c === 'bl' ? near : far) - d / 2,
366
+ top: pad + (c === 'tr' ? near : far) - d / 2,
367
+ });
368
+
369
+ const body = (
370
+ <View style={{ width: outer, height: outer, alignItems: 'center', justifyContent: 'center' }}>
371
+ {hasRing ? (
372
+ <>
373
+ <Animated.View
374
+ pointerEvents="none"
375
+ style={[
376
+ StyleSheet.absoluteFill,
377
+ {
378
+ opacity: seenV.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
379
+ transform: [
380
+ { rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) },
381
+ { scale: seenV.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96] }) },
382
+ ],
383
+ },
384
+ ]}
385
+ >
386
+ <Svg width={outer} height={outer}>
387
+ {ringLayer(ringPaint, stroke, spinning)}
388
+ </Svg>
389
+ </Animated.View>
390
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: seenV }]}>
391
+ <Svg width={outer} height={outer}>
392
+ {ringLayer(hairline, Math.max(1, stroke * 0.5), false)}
393
+ </Svg>
394
+ </Animated.View>
395
+ </>
396
+ ) : null}
397
+
398
+ {face}
399
+
400
+ {statusCorner ? (
401
+ <View pointerEvents="none" style={at(statusCorner, dot + cut * 2)}>
402
+ {showPulse ? (
403
+ <Animated.View
404
+ style={[
405
+ styles.pulse,
406
+ {
407
+ left: cut,
408
+ top: cut,
409
+ width: dot,
410
+ height: dot,
411
+ borderRadius: radius.full,
412
+ backgroundColor: statusTone(status!, colors),
413
+ opacity: pulseV.interpolate({ inputRange: [0, 1], outputRange: [0.55, 0] }),
414
+ transform: [{ scale: pulseV.interpolate({ inputRange: [0, 1], outputRange: [1, 2.6] }) }],
415
+ },
416
+ ]}
417
+ />
418
+ ) : null}
419
+ <StatusDot status={status!} size={dot} cut={cut} surface={bg} />
420
+ </View>
421
+ ) : null}
422
+
423
+ {sealCorner ? (
424
+ <View pointerEvents="none" style={at(sealCorner, seal + cut * 2)}>
425
+ <VerifiedSeal size={seal} cut={cut} color={tint} surface={bg} />
426
+ </View>
427
+ ) : null}
428
+
429
+ {showBadge ? (
430
+ <View
431
+ pointerEvents="none"
432
+ style={[
433
+ styles.badge,
434
+ {
435
+ minWidth: badgeH + cut * 2,
436
+ height: badgeH + cut * 2,
437
+ borderRadius: radius.full,
438
+ borderWidth: cut,
439
+ borderColor: bg,
440
+ backgroundColor: colors.danger,
441
+ paddingHorizontal: badgeText && badgeText.length > 1 ? 3 + cut : 0,
442
+ left: pad + far - (badgeH + cut * 2) / 2,
443
+ top: pad + near - (badgeH + cut * 2) / 2,
444
+ },
445
+ badge === true ? { minWidth: dot + cut * 2, height: dot + cut * 2, left: pad + far - dot / 2 - cut, top: pad + near - dot / 2 - cut } : null,
446
+ ]}
447
+ >
448
+ {badgeText ? (
449
+ <Text
450
+ allowFontScaling={false}
451
+ style={[styles.badgeText, { color: colors.dangerForeground, fontSize: Math.max(9, badgeH * 0.6) }]}
452
+ >
453
+ {badgeText}
454
+ </Text>
455
+ ) : null}
456
+ </View>
457
+ ) : null}
458
+ </View>
459
+ );
460
+
461
+ if (!onPress) {
462
+ return (
463
+ <View accessible accessibilityRole="image" accessibilityLabel={label} style={style}>
464
+ {body}
465
+ </View>
466
+ );
467
+ }
468
+
469
+ const springTo = (to: number) =>
470
+ Animated.spring(press, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
471
+ return (
472
+ <Animated.View style={[{ transform: [{ scale: press }] }, style]}>
473
+ <Pressable
474
+ accessibilityRole="button"
475
+ accessibilityLabel={label}
476
+ hitSlop={outer < 44 ? Math.ceil((44 - outer) / 2) : undefined}
477
+ onPress={() => {
478
+ if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
479
+ onPress();
480
+ }}
481
+ onPressIn={() => springTo(reduceMotion ? 1 : 0.93)}
482
+ onPressOut={() => springTo(1)}
483
+ >
484
+ {body}
485
+ </Pressable>
486
+ </Animated.View>
487
+ );
488
+ }
489
+
490
+ /** Presence dot inside its cut-out: filled online, crescent away, bar busy, hollow offline. */
491
+ function StatusDot({ status, size, cut, surface }: { status: AvatarStatus; size: number; cut: number; surface: string }) {
492
+ const { colors, radius } = useKit();
493
+ const tone = statusTone(status, colors);
494
+ const ring = Math.max(1.5, size * 0.2);
495
+ return (
496
+ <View
497
+ style={{
498
+ width: size + cut * 2,
499
+ height: size + cut * 2,
500
+ borderRadius: radius.full,
501
+ backgroundColor: surface,
502
+ alignItems: 'center',
503
+ justifyContent: 'center',
504
+ }}
505
+ >
506
+ <View
507
+ style={[
508
+ styles.dot,
509
+ {
510
+ width: size,
511
+ height: size,
512
+ borderRadius: radius.full,
513
+ backgroundColor: status === 'offline' ? surface : tone,
514
+ borderWidth: status === 'offline' ? ring : 0,
515
+ borderColor: tone,
516
+ },
517
+ ]}
518
+ >
519
+ {status === 'away' ? (
520
+ <View
521
+ style={{
522
+ position: 'absolute',
523
+ left: -size * 0.12,
524
+ top: -size * 0.12,
525
+ width: size * 0.62,
526
+ height: size * 0.62,
527
+ borderRadius: radius.full,
528
+ backgroundColor: surface,
529
+ }}
530
+ />
531
+ ) : null}
532
+ {status === 'busy' ? (
533
+ <View
534
+ style={{
535
+ width: size * 0.56,
536
+ height: Math.max(1.5, size * 0.18),
537
+ borderRadius: radius.full,
538
+ backgroundColor: surface,
539
+ }}
540
+ />
541
+ ) : null}
542
+ </View>
543
+ </View>
544
+ );
545
+ }
546
+
547
+ function statusTone(status: AvatarStatus, colors: ReturnType<typeof useKit>['colors']) {
548
+ return { online: colors.success, away: colors.warning, busy: colors.danger, offline: colors.mutedForeground }[status];
549
+ }
550
+
551
+ /** Eight-lobed rosette with a check, ringed by the surface colour so it reads as cut out. */
552
+ function VerifiedSeal({ size, cut, color, surface }: { size: number; cut: number; color: string; surface: string }) {
553
+ const p = (cut * 24) / size;
554
+ const w = size + cut * 2;
555
+ const ink = contrast('#ffffff', color) >= 2.4 ? '#ffffff' : '#09090b';
556
+ return (
557
+ <Svg width={w} height={w} viewBox={`${-p} ${-p} ${24 + p * 2} ${24 + p * 2}`}>
558
+ <Path d={SEAL} fill={surface} stroke={surface} strokeWidth={p * 2} strokeLinejoin="round" />
559
+ <Path d={SEAL} fill={color} />
560
+ <Path d="M8 12.4l2.8 2.8 5.4-5.6" stroke={ink} strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" fill="none" />
561
+ </Svg>
562
+ );
563
+ }
564
+
565
+ const SEAL = (() => {
566
+ const n = 8;
567
+ const pts = Array.from({ length: n * 2 }, (_, k) => {
568
+ const a = (k * Math.PI) / n - Math.PI / 2;
569
+ const rr = k % 2 === 0 ? 12 : 10.1;
570
+ return [12 + rr * Math.cos(a), 12 + rr * Math.sin(a)] as const;
571
+ });
572
+ const mid = (a: readonly number[], b: readonly number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
573
+ const f = (v: number) => v.toFixed(2);
574
+ const start = mid(pts[pts.length - 1], pts[0]);
575
+ let d = `M${f(start[0])} ${f(start[1])}`;
576
+ pts.forEach((pt, i) => {
577
+ const m = mid(pt, pts[(i + 1) % pts.length]);
578
+ d += `Q${f(pt[0])} ${f(pt[1])} ${f(m[0])} ${f(m[1])}`;
579
+ });
580
+ return d + 'Z';
581
+ })();
582
+
583
+ function PersonIcon({ size, color }: { size: number; color: string }) {
584
+ const s = size * 0.56;
585
+ return (
586
+ <Svg width={s} height={s} viewBox="0 0 24 24" fill="none">
587
+ <Circle cx={12} cy={9} r={3.6} fill={color} />
588
+ <Path d="M5.5 19.2c.9-3.2 3.4-5 6.5-5s5.6 1.8 6.5 5" stroke={color} strokeWidth={2.2} strokeLinecap="round" />
589
+ </Svg>
590
+ );
591
+ }
592
+
593
+ export interface AvatarGroupProps {
594
+ /** `<Avatar>` elements. The group sets their size, shape and surface. */
595
+ children: React.ReactNode;
596
+ /** Show at most this many, then a "+N" chip. */
597
+ max?: number;
598
+ size?: AvatarSize | number;
599
+ shape?: AvatarShape;
600
+ /** How far each avatar tucks under the previous one, as a fraction of its size (default 0.28). */
601
+ overlap?: number;
602
+ /** Real total when it is larger than the children you pass (e.g. 1,284 members). */
603
+ total?: number;
604
+ /** Staggered spring-in on mount (skipped with reduce motion). */
605
+ animated?: boolean;
606
+ /** Colour of the separating borders — match what the group sits on (default: theme background). */
607
+ surface?: string;
608
+ /** Makes the "+N" chip a button. */
609
+ onPressMore?: () => void;
610
+ style?: StyleProp<ViewStyle>;
611
+ }
612
+
613
+ /** Overlapping avatars separated by cut-out borders, with a "+N" chip and a staggered spring-in. */
614
+ export function AvatarGroup({
615
+ children,
616
+ max = 4,
617
+ size = 'md',
618
+ shape = 'circle',
619
+ overlap = 0.28,
620
+ total,
621
+ animated = true,
622
+ surface,
623
+ onPressMore,
624
+ style,
625
+ }: AvatarGroupProps) {
626
+ const { colors, radius, dark } = useKit();
627
+ const reduceMotion = useReduceMotion();
628
+ const px = resolveSize(size);
629
+ const bg = surface ?? colors.background;
630
+ const items = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement<AvatarProps>[];
631
+ const shown = items.slice(0, Math.max(0, max));
632
+ const extra = Math.max(0, (total ?? items.length) - shown.length);
633
+ const count = shown.length + (extra > 0 ? 1 : 0);
634
+ const border = Math.max(1.5, Math.round(px * 0.06 * 2) / 2);
635
+ const inner = shape === 'circle' ? radius.full : Math.min(px / 2, radius.scale(Math.round(px * 0.3)));
636
+ const wrapCorner = shape === 'circle' ? radius.full : inner + border;
637
+
638
+ const anims = useRef<Animated.Value[]>([]).current;
639
+ while (anims.length < count) anims.push(new Animated.Value(animated ? 0 : 1));
640
+
641
+ useEffect(() => {
642
+ if (!animated) return;
643
+ if (reduceMotion) {
644
+ anims.forEach((v) => v.setValue(1));
645
+ return;
646
+ }
647
+ Animated.stagger(
648
+ 55,
649
+ anims.slice(0, count).map((v) => Animated.spring(v, { toValue: 1, useNativeDriver: true, ...motion.bouncy })),
650
+ ).start();
651
+ // eslint-disable-next-line react-hooks/exhaustive-deps
652
+ }, [reduceMotion]);
653
+
654
+ const names = shown.map((c) => c.props.name).filter(Boolean) as string[];
655
+ const label =
656
+ names.length === 0
657
+ ? `${total ?? items.length} people`
658
+ : `${names.slice(0, 3).join(', ')}${(total ?? items.length) - Math.min(3, names.length) > 0 ? ` and ${(total ?? items.length) - Math.min(3, names.length)} more` : ''}`;
659
+
660
+ const wrap = (child: React.ReactNode, i: number, key: React.Key) => (
661
+ <Animated.View
662
+ key={key}
663
+ style={{
664
+ marginLeft: i === 0 ? 0 : -px * overlap,
665
+ borderRadius: wrapCorner,
666
+ padding: border,
667
+ backgroundColor: bg,
668
+ zIndex: count - i,
669
+ opacity: anims[i],
670
+ transform: [
671
+ { translateX: anims[i].interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) },
672
+ { scale: anims[i].interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) },
673
+ ],
674
+ }}
675
+ >
676
+ {child}
677
+ </Animated.View>
678
+ );
679
+
680
+ const extraText = extra > 999 ? `${(extra / 1000).toFixed(extra >= 10000 ? 0 : 1).replace(/\.0$/, '')}k` : String(extra);
681
+ const more =
682
+ extra > 0 ? (
683
+ <Pressable
684
+ accessibilityRole={onPressMore ? 'button' : 'text'}
685
+ accessibilityLabel={`${extra} more`}
686
+ disabled={!onPressMore}
687
+ onPress={onPressMore}
688
+ hitSlop={px < 44 ? Math.ceil((44 - px) / 2) : undefined}
689
+ style={({ pressed }) => [
690
+ styles.more,
691
+ {
692
+ width: px,
693
+ height: px,
694
+ borderRadius: inner,
695
+ backgroundColor: alpha(colors.foreground, pressed ? (dark ? 0.18 : 0.12) : dark ? 0.12 : 0.07),
696
+ },
697
+ ]}
698
+ >
699
+ <Text
700
+ allowFontScaling={false}
701
+ numberOfLines={1}
702
+ style={[styles.moreText, { color: colors.foreground, fontSize: Math.max(8, px * (extraText.length <= 2 ? 0.34 : extraText.length === 3 ? 0.29 : 0.24)) }]}
703
+ >
704
+ +{extraText}
705
+ </Text>
706
+ </Pressable>
707
+ ) : null;
708
+
709
+ return (
710
+ <View
711
+ accessible={!onPressMore}
712
+ accessibilityRole={onPressMore ? undefined : 'image'}
713
+ accessibilityLabel={onPressMore ? undefined : label}
714
+ style={[styles.group, style]}
715
+ >
716
+ {shown.map((child, i) =>
717
+ wrap(
718
+ React.cloneElement(child, {
719
+ size: px,
720
+ shape,
721
+ surface: bg,
722
+ ring: 'none',
723
+ seen: false,
724
+ elevated: false,
725
+ badge: undefined,
726
+ }),
727
+ i,
728
+ child.key ?? i,
729
+ ),
730
+ )}
731
+ {more ? wrap(more, shown.length, 'more') : null}
732
+ </View>
733
+ );
734
+ }
735
+
736
+ function useReduceMotion() {
737
+ const [reduce, setReduce] = useState(false);
738
+ useEffect(() => {
739
+ let alive = true;
740
+ AccessibilityInfo.isReduceMotionEnabled()
741
+ .then((v) => alive && setReduce(v))
742
+ .catch(() => {});
743
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
744
+ return () => {
745
+ alive = false;
746
+ sub.remove();
747
+ };
748
+ }, []);
749
+ return reduce;
750
+ }
751
+
752
+ function hsl(h: number, s: number, l: number) {
753
+ const hue = ((h % 360) + 360) % 360;
754
+ const c = (1 - Math.abs(2 * l - 1)) * s;
755
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
756
+ const m = l - c / 2;
757
+ const [r, g, b] =
758
+ hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x];
759
+ return '#' + [r, g, b].map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('');
760
+ }
761
+
762
+ function luminance(hex: string) {
763
+ const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;
764
+ const n = parseInt(full.slice(1, 7), 16);
765
+ if (Number.isNaN(n)) return 0;
766
+ const lin = (v: number) => {
767
+ const c = v / 255;
768
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
769
+ };
770
+ return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
771
+ }
772
+
773
+ function contrast(a: string, b: string) {
774
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
775
+ return (x + 0.05) / (y + 0.05);
776
+ }
777
+
778
+ const styles = StyleSheet.create({
779
+ face: { overflow: 'visible' },
780
+ clip: { flex: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
781
+ initials: { fontWeight: '400', includeFontPadding: false, textAlign: 'center' },
782
+ inkShadow: { textShadowColor: 'rgba(0,0,0,0.18)', textShadowRadius: 2, textShadowOffset: { width: 0, height: 0.5 } },
783
+ pulse: { position: 'absolute' },
784
+ dot: { alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
785
+ badge: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
786
+ badgeText: { fontWeight: '400', letterSpacing: -0.2, includeFontPadding: false },
787
+ group: { flexDirection: 'row', alignItems: 'center' },
788
+ more: { alignItems: 'center', justifyContent: 'center' },
789
+ moreText: { fontWeight: '400', letterSpacing: -0.2 },
790
+ });