@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,626 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ type LayoutChangeEvent,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ } from 'react-native';
13
+ import Svg, { Circle, Path } from 'react-native-svg';
14
+
15
+ import { alpha, isTextNode, motion, readableOn, type Colors, useKit } from '@/lib/kit';
16
+
17
+ export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart' | 'clock' | 'refresh' | 'check' | 'alert';
18
+
19
+ const ICON_STROKE = { strokeWidth: 1.85, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' as const };
20
+
21
+ /** Line icon for notification anchors and badge labels. */
22
+ export function BadgeIcon({ name, color, size = 22 }: { name: BadgeIconName; color: string; size?: number }) {
23
+ const p = { ...ICON_STROKE, stroke: color };
24
+ const body: Record<BadgeIconName, React.ReactNode> = {
25
+ bell: (
26
+ <>
27
+ <Path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" {...p} />
28
+ <Path d="M10 21h4" {...p} />
29
+ </>
30
+ ),
31
+ message: (
32
+ <>
33
+ <Path d="M5 5h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H9l-4 4V7a2 2 0 0 1 2-2z" {...p} />
34
+ </>
35
+ ),
36
+ package: (
37
+ <>
38
+ <Path d="M4 8l8-4 8 4v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8z" {...p} />
39
+ <Path d="M4 8l8 4 8-4M12 12v9" {...p} />
40
+ </>
41
+ ),
42
+ cart: (
43
+ <>
44
+ <Path d="M6 6h15l-1.5 7H8L6 6z" {...p} />
45
+ <Circle cx={10} cy={19} r={1.4} fill={color} />
46
+ <Circle cx={17} cy={19} r={1.4} fill={color} />
47
+ <Path d="M6 6L5 3H2" {...p} />
48
+ </>
49
+ ),
50
+ clock: (
51
+ <>
52
+ <Circle cx={12} cy={12} r={8.5} {...p} />
53
+ <Path d="M12 7.5V12l3 2" {...p} />
54
+ </>
55
+ ),
56
+ refresh: <Path d="M19 12a7 7 0 1 1-2.05-4.95M19 4.5V8h-3.5" {...p} />,
57
+ check: <Path d="M5 12.5l4.5 4.5L19 7.5" {...p} />,
58
+ alert: (
59
+ <>
60
+ <Circle cx={12} cy={12} r={8.5} {...p} />
61
+ <Path d="M12 7.5v5M12 16.2v.3" {...p} />
62
+ </>
63
+ ),
64
+ heart: <Path d="M12 20.5l-1.1-1C6.1 15.1 3 12.2 3 8.6A4.6 4.6 0 0 1 7.6 4c2 0 3.1 1.2 4.4 2.5C13.3 5.2 14.4 4 16.4 4A4.6 4.6 0 0 1 21 8.6c0 3.6-3.1 6.5-7.9 10.9L12 20.5z" {...p} />,
65
+ };
66
+ return (
67
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
68
+ {body[name]}
69
+ </Svg>
70
+ );
71
+ }
72
+
73
+ export interface BadgeIconTileProps {
74
+ /** Built-in icon name. */
75
+ name?: BadgeIconName;
76
+ /** Your own icon node — overrides `name`. */
77
+ icon?: React.ReactNode;
78
+ /** Tile size in points (default 48). */
79
+ size?: number;
80
+ /** Glyph colour (default: foreground). */
81
+ color?: string;
82
+ /** Tile background (default: muted). */
83
+ backgroundColor?: string;
84
+ style?: StyleProp<ViewStyle>;
85
+ }
86
+
87
+ /** Rounded square anchor for `NotificationBadge` — pass a built-in `name` or your own `icon`. */
88
+ export function BadgeIconTile({ name, icon, size = 48, color, backgroundColor, style }: BadgeIconTileProps) {
89
+ const { colors, radius } = useKit();
90
+ const tint = color ?? colors.foreground;
91
+ const glyph = icon ?? (name ? <BadgeIcon name={name} color={tint} size={Math.round(size * 0.46)} /> : null);
92
+ return (
93
+ <View
94
+ style={[
95
+ {
96
+ width: size,
97
+ height: size,
98
+ borderRadius: radius.scale(14),
99
+ backgroundColor: backgroundColor ?? colors.muted,
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ },
103
+ style,
104
+ ]}
105
+ >
106
+ {glyph}
107
+ </View>
108
+ );
109
+ }
110
+
111
+ export type BadgeVariant = 'solid' | 'soft' | 'outline' | 'dot';
112
+ export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger';
113
+ export type BadgeSize = 'sm' | 'md' | 'lg';
114
+
115
+ export interface BadgeProps {
116
+ children: React.ReactNode;
117
+ variant?: BadgeVariant;
118
+ tone?: BadgeTone;
119
+ size?: BadgeSize;
120
+ /** Custom colour — overrides `tone`. */
121
+ color?: string;
122
+ /** Icon or emoji before the label. */
123
+ icon?: React.ReactNode;
124
+ /** 'pill' (default) or 'rounded' corners. */
125
+ shape?: 'pill' | 'rounded';
126
+ /** Pulse the leading dot (dot variant) — "live" states. */
127
+ pulse?: boolean;
128
+ /**
129
+ * When the label, icon or colour changes: roll the old label up and the new one in, pop the icon,
130
+ * cross-fade the colours and spring the width (default true). Never animates on mount.
131
+ */
132
+ animateChanges?: boolean;
133
+ style?: StyleProp<ViewStyle>;
134
+ }
135
+
136
+ const SIZES: Record<BadgeSize, { h: number; px: number; font: number; dot: number; gap: number }> = {
137
+ sm: { h: 20, px: 7, font: 11, dot: 6, gap: 4 },
138
+ md: { h: 24, px: 9, font: 12.5, dot: 7, gap: 5 },
139
+ lg: { h: 28, px: 11, font: 14, dot: 8, gap: 6 },
140
+ };
141
+
142
+ function toneColor(colors: Colors, tone: BadgeTone) {
143
+ return { neutral: colors.mutedForeground, accent: colors.accent, success: colors.success, warning: colors.warning, danger: colors.danger }[tone];
144
+ }
145
+
146
+ function useReduceMotion() {
147
+ const [reduce, setReduce] = useState(false);
148
+ useEffect(() => {
149
+ let alive = true;
150
+ AccessibilityInfo.isReduceMotionEnabled()
151
+ .then((r) => alive && setReduce(r))
152
+ .catch(() => {});
153
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
154
+ return () => {
155
+ alive = false;
156
+ sub.remove();
157
+ };
158
+ }, []);
159
+ return reduce;
160
+ }
161
+
162
+ interface Look {
163
+ bg: string;
164
+ fg: string;
165
+ border: string;
166
+ dot: string;
167
+ }
168
+
169
+ interface Change {
170
+ /** Outgoing label rolling up and out; the current label rolls in under it. */
171
+ text?: { from: string; fg: string; perLetter: boolean; duration: number };
172
+ /** Outgoing icon (null when an icon was just added). */
173
+ icon?: { from: React.ReactNode };
174
+ /** Colours being faded from. */
175
+ look?: Look;
176
+ }
177
+
178
+ /** Short Latin labels roll letter by letter; anything else (CJK, emoji, long text) rolls as one piece. */
179
+ const LATIN = /^[A-Za-z0-9À-ɏ .,:;'’&+\-!?#%()/]{1,18}$/;
180
+ /** Roll timing in ms: delay between letters, one letter's travel, and how far the new label trails the old. */
181
+ const STAGGER = 24;
182
+ const ROLL = 320;
183
+ const LEAD = 70;
184
+
185
+ function labelText(node: React.ReactNode): string | null {
186
+ if (!isTextNode(node)) return null;
187
+ return Array.isArray(node) ? node.join('') : String(node);
188
+ }
189
+
190
+ function sameValue(a: unknown, b: unknown) {
191
+ if (Object.is(a, b)) return true;
192
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || React.isValidElement(a) || React.isValidElement(b)) return false;
193
+ try {
194
+ return JSON.stringify(a) === JSON.stringify(b);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /** Icons compare by type, key and props (ignoring colour), so an inline `<BadgeIcon />` only "changes" with its glyph. */
201
+ function sameIcon(a: React.ReactNode, b: React.ReactNode) {
202
+ if (Object.is(a, b)) return true;
203
+ if (!React.isValidElement(a) || !React.isValidElement(b)) return false;
204
+ if (a.type !== b.type || a.key !== b.key) return false;
205
+ const pa = a.props as Record<string, unknown>;
206
+ const pb = b.props as Record<string, unknown>;
207
+ for (const k of new Set([...Object.keys(pa), ...Object.keys(pb)])) {
208
+ if (k !== 'color' && !sameValue(pa[k], pb[k])) return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ /** Eased move from `from` to `to` while `v` runs start → end (held outside), sampled for the native driver. */
214
+ function span(v: Animated.Value, start: number, end: number, from: number, to: number, ease: (t: number) => number) {
215
+ const inputRange: number[] = [];
216
+ const outputRange: number[] = [];
217
+ for (let k = 0; k <= 10; k++) {
218
+ inputRange.push(start + ((end - start) * k) / 10);
219
+ outputRange.push(from + (to - from) * ease(k / 10));
220
+ }
221
+ return v.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
222
+ }
223
+
224
+ const easeIn = Easing.in(Easing.cubic);
225
+ const easeBack = Easing.out(Easing.back(1.7));
226
+ const easeLinear = Easing.linear;
227
+
228
+ /** One side of a label roll: letters (or the whole label) slide up out of / up into the clipped slot. */
229
+ function RollingLabel({
230
+ text,
231
+ color,
232
+ fontSize,
233
+ progress,
234
+ out,
235
+ perLetter,
236
+ duration,
237
+ }: {
238
+ text: string;
239
+ color: string;
240
+ fontSize: number;
241
+ progress: Animated.Value;
242
+ out: boolean;
243
+ perLetter: boolean;
244
+ duration: number;
245
+ }) {
246
+ const pieces = perLetter ? [...text].map((ch) => (ch === ' ' ? ' ' : ch)) : [text];
247
+ const distance = Math.ceil(fontSize * 1.35);
248
+ return (
249
+ <View pointerEvents="none" style={styles.roll}>
250
+ {pieces.map((piece, i) => {
251
+ const begin = ((out ? 0 : LEAD) + i * STAGGER) / duration;
252
+ const travel = (out ? ROLL * 0.7 : ROLL) / duration;
253
+ const end = Math.min(1, begin + travel);
254
+ const fade = Math.min(1, begin + travel * 0.5);
255
+ return (
256
+ <Animated.View
257
+ key={i}
258
+ style={{
259
+ opacity: out ? span(progress, begin, fade, 1, 0, easeLinear) : span(progress, begin, fade, 0, 1, easeLinear),
260
+ transform: [
261
+ { translateY: out ? span(progress, begin, end, 0, -distance, easeIn) : span(progress, begin, end, distance, 0, easeBack) },
262
+ ],
263
+ }}
264
+ >
265
+ <Text numberOfLines={1} allowFontScaling={false} style={[styles.label, { color, fontSize }]}>
266
+ {piece}
267
+ </Text>
268
+ </Animated.View>
269
+ );
270
+ })}
271
+ </View>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Small status label: solid, soft, outline or dot — five tones, three sizes. When its text, icon or
277
+ * tone changes, the label rolls over, the icon pops, the colour cross-fades and the pill springs to size.
278
+ */
279
+ export function Badge({
280
+ children,
281
+ variant = 'soft',
282
+ tone = 'neutral',
283
+ size = 'md',
284
+ color,
285
+ icon,
286
+ shape = 'pill',
287
+ pulse,
288
+ animateChanges = true,
289
+ style,
290
+ }: BadgeProps) {
291
+ const { colors, radius, dark } = useKit();
292
+ const reduce = useReduceMotion();
293
+ const s = SIZES[size];
294
+ const c = color ?? toneColor(colors, tone);
295
+ const neutral = !color && tone === 'neutral';
296
+ const pulseV = useRef(new Animated.Value(0)).current;
297
+
298
+ useEffect(() => {
299
+ if (!pulse || variant !== 'dot') return;
300
+ const loop = Animated.loop(
301
+ Animated.timing(pulseV, { toValue: 1, duration: 1400, easing: Easing.out(Easing.quad), useNativeDriver: true })
302
+ );
303
+ loop.start();
304
+ return () => loop.stop();
305
+ }, [pulse, variant, pulseV]);
306
+
307
+ const look: Record<BadgeVariant, { bg: string; fg: string; border: string }> = {
308
+ solid: neutral
309
+ ? { bg: colors.primary, fg: colors.primaryForeground, border: 'transparent' }
310
+ : { bg: c, fg: color ? readableOn(c) : tone === 'accent' ? colors.accentForeground : readableOn(c), border: 'transparent' },
311
+ soft: neutral
312
+ ? { bg: colors.muted, fg: colors.foreground, border: 'transparent' }
313
+ : { bg: alpha(c, dark ? 0.2 : 0.13), fg: c, border: 'transparent' },
314
+ outline: neutral
315
+ ? { bg: 'transparent', fg: colors.foreground, border: colors.border }
316
+ : { bg: 'transparent', fg: c, border: alpha(c, 0.45) },
317
+ dot: { bg: 'transparent', fg: colors.foreground, border: colors.border },
318
+ };
319
+ const { bg, fg, border } = look[variant];
320
+ const dotColor = neutral ? colors.mutedForeground : c;
321
+ const text = labelText(children);
322
+
323
+ // Change tracking: diff against the last render and start a transition — never on mount.
324
+ const [last, setLast] = useState({ seq: 0, text, icon, look: { bg, fg, border, dot: dotColor } as Look });
325
+ const [change, setChange] = useState<Change | null>(null);
326
+ // While set, the pill keeps this width (number), then springs (Animated.Value) to the new content.
327
+ const [pin, setPin] = useState<number | Animated.Value | null>(null);
328
+ const natural = useRef<number | null>(null);
329
+ const rowWidth = useRef<number | null>(null);
330
+ const widthV = useRef(new Animated.Value(0)).current;
331
+ const flat = StyleSheet.flatten(style) ?? {};
332
+ const canSize = flat.width == null && flat.minWidth == null && flat.flex == null && flat.flexGrow == null && flat.alignSelf !== 'stretch';
333
+
334
+ const textChanged = text !== last.text;
335
+ const iconChanged = !sameIcon(icon, last.icon);
336
+ const lookChanged = bg !== last.look.bg || fg !== last.look.fg || border !== last.look.border || dotColor !== last.look.dot;
337
+ if (textChanged || iconChanged || lookChanged) {
338
+ setLast({ seq: last.seq + 1, text, icon, look: { bg, fg, border, dot: dotColor } });
339
+ const next: Change = {};
340
+ if (animateChanges && !reduce) {
341
+ if (textChanged && last.text != null && text != null) {
342
+ const perLetter = LATIN.test(last.text) && LATIN.test(text);
343
+ const n = perLetter ? Math.max(last.text.length, text.length) : 1;
344
+ next.text = { from: last.text, fg: last.look.fg, perLetter, duration: LEAD + (n - 1) * STAGGER + ROLL };
345
+ }
346
+ if (iconChanged && icon != null) next.icon = { from: last.icon ?? null };
347
+ if (lookChanged) next.look = last.look;
348
+ }
349
+ const animated = !!(next.text || next.icon || next.look);
350
+ setChange(animated ? next : null);
351
+ setPin(animated && (textChanged || iconChanged) && canSize && natural.current != null ? natural.current : null);
352
+ }
353
+
354
+ // Fresh values per change, so the first frame of a transition already sits at its start.
355
+ const anim = useMemo(
356
+ () => ({ seq: last.seq, roll: new Animated.Value(0), pop: new Animated.Value(0), mix: new Animated.Value(0) }),
357
+ [last.seq]
358
+ );
359
+
360
+ useEffect(() => {
361
+ if (!change) return;
362
+ const runs: Animated.CompositeAnimation[] = [];
363
+ if (change.text) {
364
+ runs.push(Animated.timing(anim.roll, { toValue: 1, duration: change.text.duration, easing: easeLinear, useNativeDriver: true }));
365
+ }
366
+ if (change.icon) runs.push(Animated.spring(anim.pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }));
367
+ if (change.look) {
368
+ runs.push(Animated.timing(anim.mix, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.quad), useNativeDriver: false }));
369
+ }
370
+ let left = runs.length;
371
+ runs.forEach((r) =>
372
+ r.start(({ finished }) => {
373
+ left -= 1;
374
+ if (finished && left === 0) setChange((cur) => (cur === change ? null : cur));
375
+ })
376
+ );
377
+ return () => runs.forEach((r) => r.stop());
378
+ }, [change, anim]);
379
+
380
+ const onRoot = (e: LayoutChangeEvent) => {
381
+ if (pin == null) natural.current = e.nativeEvent.layout.width;
382
+ };
383
+ // The icon + label row remounts on every change, so this always reports the new content's width.
384
+ const onRow = (e: LayoutChangeEvent) => {
385
+ const w = e.nativeEvent.layout.width;
386
+ const prev = rowWidth.current;
387
+ rowWidth.current = w;
388
+ if (typeof pin !== 'number') return;
389
+ if (prev == null || Math.abs(w - prev) < 0.5) {
390
+ setPin(null);
391
+ return;
392
+ }
393
+ const to = Math.max(0, pin + (w - prev));
394
+ widthV.setValue(pin);
395
+ natural.current = to;
396
+ setPin(widthV);
397
+ Animated.spring(widthV, { toValue: to, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {
398
+ if (finished) setPin((p) => (p === widthV ? null : p));
399
+ });
400
+ };
401
+
402
+ const from = change?.look;
403
+ const mix = (a: string, b: string) => (from ? anim.mix.interpolate({ inputRange: [0, 1], outputRange: [a, b] }) : b);
404
+ const roll = change?.text;
405
+
406
+ let label: React.ReactNode = children;
407
+ if (text != null) {
408
+ label = (
409
+ <View style={roll ? styles.clip : undefined}>
410
+ <Animated.Text
411
+ numberOfLines={1}
412
+ allowFontScaling={false}
413
+ style={[styles.label, { color: from && !roll ? mix(from.fg, fg) : fg, fontSize: s.font }, roll ? styles.hidden : null]}
414
+ >
415
+ {text}
416
+ </Animated.Text>
417
+ {roll ? (
418
+ <>
419
+ <RollingLabel text={roll.from} color={roll.fg} fontSize={s.font} progress={anim.roll} out perLetter={roll.perLetter} duration={roll.duration} />
420
+ <RollingLabel text={text} color={fg} fontSize={s.font} progress={anim.roll} out={false} perLetter={roll.perLetter} duration={roll.duration} />
421
+ </>
422
+ ) : null}
423
+ </View>
424
+ );
425
+ }
426
+
427
+ const pop = anim.pop;
428
+ const iconNode =
429
+ icon != null && change?.icon ? (
430
+ <View>
431
+ {change.icon.from != null ? (
432
+ <Animated.View
433
+ pointerEvents="none"
434
+ style={[
435
+ styles.center,
436
+ {
437
+ opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),
438
+ transform: [
439
+ { scale: pop.interpolate({ inputRange: [0, 0.5], outputRange: [1, 0.3], extrapolate: 'clamp' }) },
440
+ { rotate: pop.interpolate({ inputRange: [0, 0.5], outputRange: ['0deg', '100deg'], extrapolate: 'clamp' }) },
441
+ ],
442
+ },
443
+ ]}
444
+ >
445
+ {change.icon.from}
446
+ </Animated.View>
447
+ ) : null}
448
+ <Animated.View
449
+ style={{
450
+ opacity: pop.interpolate({ inputRange: [0, 0.35], outputRange: [0, 1], extrapolate: 'clamp' }),
451
+ transform: [
452
+ { scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
453
+ { rotate: pop.interpolate({ inputRange: [0, 1], outputRange: ['-100deg', '0deg'] }) },
454
+ ],
455
+ }}
456
+ >
457
+ {icon}
458
+ </Animated.View>
459
+ </View>
460
+ ) : (
461
+ icon
462
+ );
463
+
464
+ return (
465
+ <Animated.View
466
+ accessible={text != null ? true : undefined}
467
+ accessibilityRole="text"
468
+ accessibilityLabel={text ?? undefined}
469
+ accessibilityLiveRegion={animateChanges && text != null ? 'polite' : undefined}
470
+ onLayout={animateChanges ? onRoot : undefined}
471
+ style={[
472
+ styles.base,
473
+ {
474
+ height: s.h,
475
+ paddingHorizontal: s.px,
476
+ gap: s.gap,
477
+ backgroundColor: from ? mix(from.bg, bg) : bg,
478
+ borderColor: from ? mix(from.border, border) : border,
479
+ borderRadius: shape === 'pill' ? radius.capsule(s.h) : radius.sm,
480
+ },
481
+ pin != null ? { width: pin, overflow: 'hidden' as const } : null,
482
+ style,
483
+ ]}
484
+ >
485
+ {variant === 'dot' ? (
486
+ <View style={{ width: s.dot, height: s.dot }}>
487
+ {pulse ? (
488
+ <Animated.View
489
+ style={[
490
+ styles.fill,
491
+ {
492
+ borderRadius: s.dot,
493
+ backgroundColor: dotColor,
494
+ opacity: pulseV.interpolate({ inputRange: [0, 1], outputRange: [0.6, 0] }),
495
+ transform: [{ scale: pulseV.interpolate({ inputRange: [0, 1], outputRange: [1, 2.6] }) }],
496
+ },
497
+ ]}
498
+ />
499
+ ) : null}
500
+ {/* Colour fades on the JS driver, so it lives on this view — the pulse above owns the native transforms. */}
501
+ <Animated.View style={[styles.fill, { borderRadius: s.dot, backgroundColor: from ? mix(from.dot, dotColor) : dotColor }]} />
502
+ </View>
503
+ ) : null}
504
+ <View key={last.seq} onLayout={animateChanges ? onRow : undefined} style={[styles.row, { gap: s.gap }]}>
505
+ {iconNode}
506
+ {label}
507
+ </View>
508
+ </Animated.View>
509
+ );
510
+ }
511
+
512
+ export interface NotificationBadgeProps {
513
+ /** The number to show. 0 hides the badge (unless `showZero`). */
514
+ count?: number;
515
+ /** Above this, show "99+". */
516
+ max?: number;
517
+ /** Just a dot, no number. */
518
+ dot?: boolean;
519
+ showZero?: boolean;
520
+ tone?: BadgeTone;
521
+ color?: string;
522
+ size?: 'sm' | 'md';
523
+ /** What the badge sits on (icon, avatar, button). Omit to render the badge inline. */
524
+ children?: React.ReactNode;
525
+ /** Nudge the badge from the top-right corner: [x, y] in points. */
526
+ offset?: [number, number];
527
+ style?: StyleProp<ViewStyle>;
528
+ }
529
+
530
+ /** Count bubble that springs in, pops every time the number changes, and shrinks away at zero. */
531
+ export function NotificationBadge({
532
+ count = 0,
533
+ max = 99,
534
+ dot,
535
+ showZero,
536
+ tone = 'danger',
537
+ color,
538
+ size = 'md',
539
+ children,
540
+ offset = [0, 0],
541
+ style,
542
+ }: NotificationBadgeProps) {
543
+ const { colors } = useKit();
544
+ const c = color ?? toneColor(colors, tone);
545
+ const fg = !color && tone === 'accent' ? colors.accentForeground : readableOn(c);
546
+ const visible = count > 0 || !!showZero;
547
+
548
+ // Keep the last number on screen while it shrinks away.
549
+ const [shown, setShown] = useState(count);
550
+ const show = useRef(new Animated.Value(visible ? 1 : 0)).current;
551
+ const pop = useRef(new Animated.Value(1)).current;
552
+ const prev = useRef(count);
553
+
554
+ useEffect(() => {
555
+ if (visible) setShown(count);
556
+ Animated.spring(show, { toValue: visible ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
557
+ if (visible && prev.current !== count && prev.current > 0) {
558
+ pop.setValue(1.35);
559
+ Animated.spring(pop, { toValue: 1, useNativeDriver: true, damping: 9, stiffness: 320, mass: 0.6 }).start();
560
+ }
561
+ prev.current = count;
562
+ }, [count, visible, show, pop]);
563
+
564
+ const h = size === 'sm' ? 18 : 22; // includes the 2pt ring
565
+ const d = size === 'sm' ? 11 : 13;
566
+ const text = shown > max ? `${max}+` : String(shown);
567
+
568
+ const bubble = (
569
+ <Animated.View
570
+ pointerEvents="none"
571
+ accessibilityElementsHidden
572
+ importantForAccessibility="no-hide-descendants"
573
+ style={[
574
+ styles.bubble,
575
+ dot
576
+ ? { width: d, height: d, borderRadius: d / 2 }
577
+ : { minWidth: h, height: h, borderRadius: h / 2, paddingHorizontal: text.length > 1 ? 4 : 0 },
578
+ {
579
+ backgroundColor: c,
580
+ borderColor: colors.background,
581
+ opacity: show.interpolate({ inputRange: [0, 0.3, 1], outputRange: [0, 1, 1] }),
582
+ transform: [{ scale: Animated.multiply(show, pop) }],
583
+ },
584
+ children
585
+ ? { position: 'absolute', top: -(dot ? d : h) / 3 + offset[1], right: -(dot ? d : h) / 3 - offset[0] }
586
+ : null,
587
+ ]}
588
+ >
589
+ {dot ? null : (
590
+ <Text allowFontScaling={false} numberOfLines={1} style={[styles.count, { color: fg, fontSize: size === 'sm' ? 10 : 11.5 }]}>
591
+ {text}
592
+ </Text>
593
+ )}
594
+ </Animated.View>
595
+ );
596
+
597
+ const a11y = visible ? (dot ? 'New activity' : `${count} notification${count === 1 ? '' : 's'}`) : undefined;
598
+ if (!children) {
599
+ return (
600
+ <View accessibilityLabel={a11y} style={style}>
601
+ {bubble}
602
+ </View>
603
+ );
604
+ }
605
+ return (
606
+ <View accessibilityLabel={a11y} style={[styles.anchor, style]}>
607
+ {children}
608
+ {bubble}
609
+ </View>
610
+ );
611
+ }
612
+
613
+ const styles = StyleSheet.create({
614
+ base: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', borderWidth: 1 },
615
+ label: { fontWeight: '400', letterSpacing: 0.1 },
616
+ row: { flexDirection: 'row', alignItems: 'center' },
617
+ // Clips the roll; the 2pt slack (cancelled by negative margins) keeps overshoot and unkerned letters whole.
618
+ clip: { overflow: 'hidden', padding: 2, margin: -2 },
619
+ hidden: { opacity: 0 },
620
+ roll: { position: 'absolute', top: 2, left: 2, flexDirection: 'row' },
621
+ center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
622
+ fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
623
+ anchor: { alignSelf: 'flex-start' },
624
+ bubble: { alignItems: 'center', justifyContent: 'center', borderWidth: 2 },
625
+ count: { fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false },
626
+ });