@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,1059 @@
1
+ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ Platform,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ type LayoutChangeEvent,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+ import Svg, { Circle, Defs, Line, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
16
+
17
+ import { Avatar } from '@/components/ui/avatar';
18
+ import { alpha, isTextNode, motion, readableOn, useKit } from '@/lib/kit';
19
+
20
+ export type TimelineStatus = 'done' | 'current' | 'upcoming' | 'error';
21
+ export type TimelineVariant = 'default' | 'compact' | 'card' | 'scroll';
22
+
23
+ export type TimelineIconName =
24
+ | 'cart'
25
+ | 'package'
26
+ | 'truck'
27
+ | 'home'
28
+ | 'rocket'
29
+ | 'bug'
30
+ | 'star'
31
+ | 'chat'
32
+ | 'tag'
33
+ | 'user';
34
+
35
+ export interface TimelineIconProps {
36
+ color: string;
37
+ size: number;
38
+ }
39
+
40
+ export type TimelineImageSource = string | { uri: string; aspectRatio?: number };
41
+
42
+ export interface TimelineLink {
43
+ label: string;
44
+ onPress: () => void;
45
+ }
46
+
47
+ export interface TimelineItem {
48
+ key?: string;
49
+ title: string;
50
+ description?: string;
51
+ /** Timestamp or meta, e.g. "Oct 12 · 2:14 PM" or "2h". */
52
+ time?: string;
53
+ /** Default 'done'. */
54
+ status?: TimelineStatus;
55
+ /** Icon inside a larger dot. */
56
+ icon?: TimelineIconName | ((props: TimelineIconProps) => React.ReactNode);
57
+ /** Photo (initials fallback) instead of a dot. */
58
+ avatar?: { src?: string; name: string };
59
+ /** A photo under the text — a URL, or `{ uri, aspectRatio }` (default 16:9). */
60
+ image?: TimelineImageSource;
61
+ /** Several photos in a 1-, 2- or 3-up grid (more than three show a "+N" tile). Combined with `image`. */
62
+ images?: TimelineImageSource[];
63
+ /** Small accent chips under the text. */
64
+ tags?: string[];
65
+ /** Tappable accent links under the text. */
66
+ links?: TimelineLink[];
67
+ /** Anything under the description: a card, a quote, tags, buttons. */
68
+ content?: React.ReactNode;
69
+ }
70
+
71
+ export interface TimelineProps {
72
+ items: TimelineItem[];
73
+ /**
74
+ * 'default' roomy rows · 'compact' small dots, one-line rows · 'card' each event on a card ·
75
+ * 'scroll' renders its own scroll view and fills the connector as you scroll (give it a height).
76
+ */
77
+ variant?: TimelineVariant;
78
+ /** Colour of done / current dots and the filled line (default: theme accent). */
79
+ accent?: string;
80
+ /** Draw a check in done dots (default true). */
81
+ checks?: boolean;
82
+ /** Entrance + connector fill, or the scroll-driven fill for 'scroll' (default true; off with reduce motion). */
83
+ animated?: boolean;
84
+ /** Colour behind the rail — fills hollow dots and the mini status badges (default: theme background). */
85
+ surface?: string;
86
+ /** 'scroll' only: rendered above the timeline inside the scroll view. */
87
+ header?: React.ReactNode;
88
+ /** 'scroll' only: rendered below the timeline inside the scroll view. */
89
+ footer?: React.ReactNode;
90
+ /** 'scroll' only: style for the scroll view's content container (default `{ paddingVertical: 12 }`). */
91
+ contentContainerStyle?: StyleProp<ViewStyle>;
92
+ /** Root style. For 'scroll' this is the scroll view, so set a `height` (or `flex: 1` on a screen). */
93
+ style?: StyleProp<ViewStyle>;
94
+ }
95
+
96
+ const ENTER_STAGGER = 70;
97
+ const FILL_START = 160;
98
+ /** Where the fill point sits in the viewport at the top of the scroll (it slides to the bottom by the end). */
99
+ const SCROLL_ANCHOR = 0.55;
100
+ /** The next node starts pulsing when the fill point is this fraction of the viewport away. */
101
+ const NEAR = 0.3;
102
+
103
+ /**
104
+ * Vertical timeline for order tracking, activity feeds and release history. Items rise in one
105
+ * by one, then the connector fills down through every reached step: done dots draw their check
106
+ * as the line arrives, the current dot starts to pulse, upcoming dots stay hollow on a dotted
107
+ * track, and failed steps draw a cross. `variant="scroll"` renders its own scroll view and ties
108
+ * the fill to the scroll position instead.
109
+ */
110
+ export function Timeline(props: TimelineProps) {
111
+ return props.variant === 'scroll' ? <ScrollTimeline {...props} /> : <StaticTimeline {...props} />;
112
+ }
113
+
114
+ function StaticTimeline({
115
+ items,
116
+ variant = 'default',
117
+ accent,
118
+ checks = true,
119
+ animated = true,
120
+ surface,
121
+ style,
122
+ }: TimelineProps) {
123
+ const { colors } = useKit();
124
+ const reduceMotion = useReduceMotion();
125
+ const tint = accent ?? colors.accent;
126
+ const bg = surface ?? colors.background;
127
+ const still = !animated || reduceMotion;
128
+ const segs = Math.max(1, items.length - 1);
129
+ const fillMs = Math.round(Math.min(280, 1400 / segs));
130
+
131
+ return (
132
+ <View accessibilityRole="list" style={style}>
133
+ {items.map((item, i) => {
134
+ const next = items[i + 1];
135
+ const status = item.status ?? 'done';
136
+ return (
137
+ <Row
138
+ key={item.key ?? `${item.title}-${i}`}
139
+ item={item}
140
+ status={status}
141
+ next={next ? (next.status ?? 'done') : null}
142
+ last={!next}
143
+ tint={tint}
144
+ surface={bg}
145
+ variant={variant}
146
+ checks={checks}
147
+ still={still}
148
+ enterDelay={i * ENTER_STAGGER}
149
+ arriveDelay={FILL_START + i * fillMs}
150
+ fillMs={fillMs}
151
+ />
152
+ );
153
+ })}
154
+ </View>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Scroll-driven timeline. One continuous rail sits behind the rows; its accent fill is a native
160
+ * scaleY driven by `scrollY`, interpolated against the node centres measured with onLayout.
161
+ * The fill point starts at 55% of the viewport and slides to the bottom edge as the scroll
162
+ * reaches its end, so the last node is always reachable without padding. Nodes light up (and
163
+ * draw their check) when the fill reaches them; the next one pulses as the fill gets close.
164
+ * The fill runs top-down through the leading steps that are not 'upcoming'.
165
+ */
166
+ function ScrollTimeline({
167
+ items,
168
+ accent,
169
+ checks = true,
170
+ animated = true,
171
+ surface,
172
+ header,
173
+ footer,
174
+ contentContainerStyle,
175
+ style,
176
+ }: TimelineProps) {
177
+ const { colors, dark } = useKit();
178
+ const reduceMotion = useReduceMotion();
179
+ const tint = accent ?? colors.accent;
180
+ const bg = surface ?? colors.background;
181
+ const still = !animated || reduceMotion;
182
+ const n = items.length;
183
+ const rail = 34;
184
+
185
+ const scrollY = useRef(new Animated.Value(0)).current;
186
+ const lastY = useRef(0);
187
+ const [viewH, setViewH] = useState(0);
188
+ const [contentH, setContentH] = useState(0);
189
+ const [listY, setListY] = useState(0);
190
+ const [nodes, setNodes] = useState<number[]>([]);
191
+
192
+ const onNode = useCallback((i: number, y: number) => {
193
+ setNodes((prev) => {
194
+ if (Math.abs((prev[i] ?? -1) - y) < 0.5) return prev;
195
+ const nextNodes = prev.slice();
196
+ nextNodes[i] = y;
197
+ return nextNodes;
198
+ });
199
+ }, []);
200
+
201
+ // Fill runs through the leading steps that have been reached.
202
+ let reach = items.findIndex((it) => (it.status ?? 'done') === 'upcoming') - 1;
203
+ if (reach < -1) reach = n - 1;
204
+
205
+ const measured = n > 0 && viewH > 0 && contentH > 0 && items.every((_, i) => nodes[i] != null);
206
+ const maxScroll = contentH - viewH;
207
+ const scrolls = measured && maxScroll > 1;
208
+ const frozen = still || (measured && !scrolls);
209
+
210
+ // Scroll offset at which the fill point (s·k + anchor) reaches each node.
211
+ const triggers = useMemo(() => {
212
+ if (!scrolls) return [] as number[];
213
+ const anchor = viewH * SCROLL_ANCHOR;
214
+ const k = 1 + (viewH - anchor) / maxScroll;
215
+ return items.map((_, i) => (listY + nodes[i] - anchor) / k);
216
+ }, [scrolls, viewH, maxScroll, listY, nodes, items]);
217
+
218
+ // progress = lit * 2 + (near ? 1 : 0), one number so React can skip identical updates.
219
+ const [progress, setProgress] = useState(0);
220
+ useEffect(() => {
221
+ if (frozen || !triggers.length) return;
222
+ const update = (y: number) => {
223
+ lastY.current = y;
224
+ let lit = 0;
225
+ while (lit <= reach && triggers[lit] <= y) lit++;
226
+ const near = lit <= reach && y >= triggers[lit] - viewH * NEAR;
227
+ setProgress(lit * 2 + (near ? 1 : 0));
228
+ };
229
+ update(lastY.current);
230
+ const id = scrollY.addListener(({ value }) => update(value));
231
+ return () => scrollY.removeListener(id);
232
+ }, [frozen, triggers, reach, viewH, scrollY]);
233
+
234
+ const lit = frozen ? n : measured ? progress >> 1 : 0;
235
+ const near = !frozen && (progress & 1) === 1;
236
+
237
+ const onScroll = useMemo(
238
+ () => Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true }),
239
+ [scrollY],
240
+ );
241
+
242
+ const top = nodes[0] ?? 0;
243
+ const fillH = reach > 0 && measured ? nodes[reach] - top : 0;
244
+ const dottedFrom = Math.max(0, reach);
245
+ const dottedH = measured && reach < n - 1 ? nodes[n - 1] - nodes[dottedFrom] : 0;
246
+
247
+ let fillStyle: Animated.WithAnimatedObject<ViewStyle> | null = null;
248
+ if (fillH > 0) {
249
+ if (frozen) {
250
+ fillStyle = {};
251
+ } else if (triggers.length) {
252
+ const t0 = triggers[0];
253
+ const t1 = Math.max(triggers[reach], t0 + 1);
254
+ const p = scrollY.interpolate({ inputRange: [t0, t1], outputRange: [0.0001, 1], extrapolate: 'clamp' });
255
+ fillStyle = {
256
+ transform: [
257
+ { translateY: p.interpolate({ inputRange: [0, 1], outputRange: [-fillH / 2, 0] }) },
258
+ { scaleY: p },
259
+ ],
260
+ };
261
+ }
262
+ }
263
+
264
+ return (
265
+ <Animated.ScrollView
266
+ style={style}
267
+ contentContainerStyle={[styles.scrollContent, contentContainerStyle]}
268
+ scrollEventThrottle={16}
269
+ onScroll={onScroll}
270
+ nestedScrollEnabled
271
+ onLayout={(e) => setViewH(e.nativeEvent.layout.height)}
272
+ onContentSizeChange={(_, h) => setContentH(h)}
273
+ >
274
+ {header}
275
+ <View accessibilityRole="list" onLayout={(e) => setListY(e.nativeEvent.layout.y)}>
276
+ {fillH > 0 ? (
277
+ <View
278
+ pointerEvents="none"
279
+ style={[styles.track, { left: rail / 2 - 1, top, height: fillH, backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]}
280
+ />
281
+ ) : null}
282
+ {dottedH > 0 ? (
283
+ <Svg
284
+ width={2}
285
+ height={dottedH}
286
+ pointerEvents="none"
287
+ style={[styles.track, { left: rail / 2 - 1, top: nodes[dottedFrom] }]}
288
+ >
289
+ <Line
290
+ x1={1}
291
+ y1={1}
292
+ x2={1}
293
+ y2={dottedH - 1}
294
+ stroke={alpha(colors.foreground, dark ? 0.28 : 0.22)}
295
+ strokeWidth={2}
296
+ strokeLinecap="round"
297
+ strokeDasharray={[0.1, 6]}
298
+ />
299
+ </Svg>
300
+ ) : null}
301
+ {fillStyle ? (
302
+ <Animated.View
303
+ pointerEvents="none"
304
+ style={[styles.track, { left: rail / 2 - 1, top, height: fillH, backgroundColor: tint }, fillStyle]}
305
+ />
306
+ ) : null}
307
+ {items.map((item, i) => {
308
+ const status = item.status ?? 'done';
309
+ const reachable = i <= reach;
310
+ const isLit = !reachable || i < lit;
311
+ const dotStatus: TimelineStatus = isLit ? status : i === lit && near ? 'current' : 'upcoming';
312
+ return (
313
+ <Row
314
+ key={item.key ?? `${item.title}-${i}`}
315
+ item={item}
316
+ status={status}
317
+ dotStatus={dotStatus}
318
+ lit={reachable && isLit}
319
+ next={null}
320
+ last={i === n - 1}
321
+ tint={tint}
322
+ surface={bg}
323
+ variant="scroll"
324
+ checks={checks}
325
+ still={still}
326
+ enterDelay={0}
327
+ arriveDelay={0}
328
+ fillMs={0}
329
+ onNode={(y) => onNode(i, y)}
330
+ />
331
+ );
332
+ })}
333
+ </View>
334
+ {footer}
335
+ </Animated.ScrollView>
336
+ );
337
+ }
338
+
339
+ function statusLabel(s: TimelineStatus) {
340
+ return s === 'done' ? 'completed' : s === 'current' ? 'in progress' : s === 'error' ? 'failed' : 'upcoming';
341
+ }
342
+
343
+ interface RowProps {
344
+ item: TimelineItem;
345
+ status: TimelineStatus;
346
+ next: TimelineStatus | null;
347
+ last: boolean;
348
+ tint: string;
349
+ surface: string;
350
+ variant: TimelineVariant;
351
+ checks: boolean;
352
+ still: boolean;
353
+ enterDelay: number;
354
+ /** When the connector reaches this row's dot. */
355
+ arriveDelay: number;
356
+ fillMs: number;
357
+ /** 'scroll': what the dot shows right now (it lights up when the fill reaches it). */
358
+ dotStatus?: TimelineStatus;
359
+ /** 'scroll': the fill has reached this dot — pops it. */
360
+ lit?: boolean;
361
+ /** 'scroll': reports the dot centre's y inside the list. */
362
+ onNode?: (y: number) => void;
363
+ }
364
+
365
+ function Row({
366
+ item,
367
+ status,
368
+ next,
369
+ last,
370
+ tint,
371
+ surface,
372
+ variant,
373
+ checks,
374
+ still,
375
+ enterDelay,
376
+ arriveDelay,
377
+ fillMs,
378
+ dotStatus,
379
+ lit,
380
+ onNode,
381
+ }: RowProps) {
382
+ const { colors, radius, dark } = useKit();
383
+ const compact = variant === 'compact';
384
+ const card = variant === 'card';
385
+ const scroll = variant === 'scroll';
386
+ const rich = !!(item.icon || item.avatar);
387
+ const dot = compact ? (rich ? 24 : 16) : rich ? 34 : 24;
388
+ const rail = compact ? 24 : 34;
389
+ const titleSize = compact ? 14.5 : 15.5;
390
+ const titleLine = compact ? 20 : 21;
391
+ const cardPad = 14;
392
+ const dotTop = Math.max(0, (card ? cardPad : 0) + (titleLine - dot) / 2);
393
+ const textTop = card ? 0 : Math.max(0, (dot - titleLine) / 2);
394
+ const gapBelow = compact ? 14 : card ? 12 : scroll ? 32 : 26;
395
+ const entrance = !still && !scroll;
396
+
397
+ const enter = useRef(new Animated.Value(entrance ? 0 : 1)).current;
398
+ const fill = useRef(new Animated.Value(still ? 1 : 0)).current;
399
+ const pop = useRef(new Animated.Value(1)).current;
400
+ const wasLit = useRef(!!lit);
401
+ const [lineH, setLineH] = useState(0);
402
+
403
+ // A segment is filled only when both ends have been reached (works for newest-first lists too).
404
+ const reached = next !== null && next !== 'upcoming' && status !== 'upcoming';
405
+
406
+ useEffect(() => {
407
+ if (!entrance) {
408
+ enter.setValue(1);
409
+ fill.setValue(1);
410
+ return;
411
+ }
412
+ enter.setValue(0);
413
+ fill.setValue(0);
414
+ const a = Animated.timing(enter, {
415
+ toValue: 1,
416
+ duration: 420,
417
+ delay: enterDelay,
418
+ easing: Easing.out(Easing.cubic),
419
+ useNativeDriver: true,
420
+ });
421
+ const b = Animated.timing(fill, {
422
+ toValue: 1,
423
+ duration: fillMs,
424
+ delay: arriveDelay + 40,
425
+ easing: Easing.inOut(Easing.quad),
426
+ useNativeDriver: true,
427
+ });
428
+ a.start();
429
+ if (reached) b.start();
430
+ return () => {
431
+ a.stop();
432
+ b.stop();
433
+ };
434
+ }, [entrance, enter, fill, enterDelay, arriveDelay, fillMs, reached]);
435
+
436
+ // Scroll: a small pop when the fill reaches this dot.
437
+ useEffect(() => {
438
+ if (lit && !wasLit.current && !still) {
439
+ pop.setValue(0.6);
440
+ Animated.spring(pop, { toValue: 1, ...motion.bouncy, useNativeDriver: true }).start();
441
+ }
442
+ wasLit.current = !!lit;
443
+ }, [lit, still, pop]);
444
+
445
+ const muted = status === 'upcoming';
446
+ const current = status === 'current';
447
+ const titleColor = muted ? colors.mutedForeground : status === 'error' ? colors.danger : colors.foreground;
448
+ const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
449
+ const photos = [...(item.image ? [item.image] : []), ...(item.images ?? [])];
450
+ const hasLinks = !!item.links?.length;
451
+
452
+ const body = (
453
+ <>
454
+ <View style={[styles.titleRow, compact && styles.compactRow]}>
455
+ <Text
456
+ numberOfLines={compact ? 1 : 2}
457
+ style={[styles.title, { fontSize: titleSize, lineHeight: titleLine, color: titleColor }]}
458
+ >
459
+ {item.title}
460
+ </Text>
461
+ {item.time ? (
462
+ <Text
463
+ numberOfLines={1}
464
+ style={[styles.time, { lineHeight: titleLine, color: current ? tint : colors.mutedForeground }]}
465
+ >
466
+ {item.time}
467
+ </Text>
468
+ ) : null}
469
+ </View>
470
+ {item.description && !compact ? (
471
+ <Text style={[styles.desc, { color: colors.mutedForeground }]}>{item.description}</Text>
472
+ ) : null}
473
+ {photos.length ? <TimelineGallery images={photos} dim={muted} /> : null}
474
+ {item.tags?.length || hasLinks ? (
475
+ <View style={styles.chips}>
476
+ {item.tags?.map((t) => (
477
+ <View
478
+ key={`t-${t}`}
479
+ style={[styles.tag, { borderRadius: radius.capsule(22), backgroundColor: alpha(tint, dark ? 0.2 : 0.1) }]}
480
+ >
481
+ <Text style={[styles.tagText, { color: tint }]}>{t}</Text>
482
+ </View>
483
+ ))}
484
+ {item.links?.map((l) => (
485
+ <Pressable
486
+ key={`l-${l.label}`}
487
+ accessibilityRole="link"
488
+ accessibilityLabel={l.label}
489
+ onPress={l.onPress}
490
+ hitSlop={12}
491
+ style={({ pressed }) => [styles.link, { opacity: pressed ? 0.55 : 1 }]}
492
+ >
493
+ <Text style={[styles.linkText, { color: tint }]}>{l.label} →</Text>
494
+ </Pressable>
495
+ ))}
496
+ </View>
497
+ ) : null}
498
+ {item.content ? (
499
+ <View style={styles.content}>
500
+ {isTextNode(item.content) ? <Text style={{ color: colors.foreground }}>{item.content}</Text> : item.content}
501
+ </View>
502
+ ) : null}
503
+ </>
504
+ );
505
+
506
+ const cardShadow: ViewStyle =
507
+ Platform.OS === 'android'
508
+ ? { elevation: current ? 3 : 1 }
509
+ : {
510
+ shadowColor: '#000',
511
+ shadowOffset: { width: 0, height: current ? 6 : 3 },
512
+ shadowOpacity: dark ? 0.35 : current ? 0.08 : 0.04,
513
+ shadowRadius: current ? 16 : 10,
514
+ };
515
+
516
+ const dotWrap = scroll
517
+ ? { transform: [{ scale: pop }] }
518
+ : {
519
+ opacity: enter,
520
+ transform: [{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) }],
521
+ };
522
+
523
+ return (
524
+ <View
525
+ accessible={!hasLinks}
526
+ accessibilityLabel={[item.title, item.time, item.description, statusLabel(status)].filter(Boolean).join(', ')}
527
+ style={styles.row}
528
+ onLayout={onNode ? (e: LayoutChangeEvent) => onNode(e.nativeEvent.layout.y + dotTop + dot / 2) : undefined}
529
+ >
530
+ <View style={[styles.rail, { width: rail }]}>
531
+ <View style={{ height: dotTop }} />
532
+ <Animated.View style={dotWrap}>
533
+ <Dot
534
+ item={item}
535
+ status={dotStatus ?? status}
536
+ size={dot}
537
+ tint={tint}
538
+ surface={surface}
539
+ checks={checks}
540
+ still={still}
541
+ delay={arriveDelay}
542
+ />
543
+ </Animated.View>
544
+ {next !== null ? (
545
+ <View
546
+ style={[styles.line, { marginTop: compact ? 3 : 5, marginBottom: (compact ? 3 : 5) - dotTop }]}
547
+ onLayout={(e) => setLineH(Math.round(e.nativeEvent.layout.height))}
548
+ >
549
+ {lineH > 0 ? (
550
+ <>
551
+ <Svg width={2} height={lineH} style={StyleSheet.absoluteFill}>
552
+ {reached ? (
553
+ <Rect width={2} height={lineH} rx={1} fill={alpha(colors.foreground, dark ? 0.1 : 0.07)} />
554
+ ) : (
555
+ <Line
556
+ x1={1}
557
+ y1={1}
558
+ x2={1}
559
+ y2={lineH - 1}
560
+ stroke={alpha(colors.foreground, dark ? 0.28 : 0.22)}
561
+ strokeWidth={2}
562
+ strokeLinecap="round"
563
+ strokeDasharray={[0.1, 6]}
564
+ />
565
+ )}
566
+ </Svg>
567
+ {reached ? (
568
+ <Animated.View
569
+ pointerEvents="none"
570
+ style={{
571
+ position: 'absolute',
572
+ left: 0,
573
+ top: 0,
574
+ width: 2,
575
+ height: lineH,
576
+ borderRadius: 1,
577
+ backgroundColor: tint,
578
+ transform: [
579
+ { translateY: fill.interpolate({ inputRange: [0, 1], outputRange: [-lineH / 2, 0] }) },
580
+ { scaleY: fill.interpolate({ inputRange: [0, 1], outputRange: [0.001, 1] }) },
581
+ ],
582
+ }}
583
+ />
584
+ ) : null}
585
+ </>
586
+ ) : null}
587
+ </View>
588
+ ) : null}
589
+ </View>
590
+
591
+ <Animated.View
592
+ style={[
593
+ styles.bodyWrap,
594
+ {
595
+ paddingBottom: last ? 0 : gapBelow,
596
+ paddingTop: textTop,
597
+ opacity: enter,
598
+ transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }) }],
599
+ },
600
+ ]}
601
+ >
602
+ {card ? (
603
+ <View
604
+ style={[
605
+ styles.card,
606
+ cardShadow,
607
+ {
608
+ padding: cardPad,
609
+ borderRadius: radius.lg,
610
+ backgroundColor: colors.card,
611
+ borderColor: current ? alpha(tint, dark ? 0.45 : 0.32) : status === 'error' ? alpha(colors.danger, 0.3) : hairline,
612
+ },
613
+ ]}
614
+ >
615
+ {current ? (
616
+ <View
617
+ pointerEvents="none"
618
+ style={[StyleSheet.absoluteFill, { borderRadius: radius.lg, backgroundColor: alpha(tint, dark ? 0.08 : 0.045) }]}
619
+ />
620
+ ) : null}
621
+ {body}
622
+ </View>
623
+ ) : (
624
+ body
625
+ )}
626
+ </Animated.View>
627
+ </View>
628
+ );
629
+ }
630
+
631
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
632
+ const CHECK = 'M7.2 12.4l3.2 3.2 6.4-6.6';
633
+ const CHECK_LEN = 15;
634
+ const CROSS_A = 'M8.6 8.6l6.8 6.8';
635
+ const CROSS_B = 'M15.4 8.6l-6.8 6.8';
636
+ const CROSS_LEN = 10;
637
+
638
+ /** Check or cross that draws itself (JS-driven dash offset on its own Svg). */
639
+ function DrawnMark({ kind, color, size, draw }: { kind: 'check' | 'cross'; color: string; size: number; draw: Animated.Value }) {
640
+ const s = { stroke: color, strokeWidth: 2.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
641
+ return (
642
+ <Svg width={size} height={size} viewBox="0 0 24 24">
643
+ {kind === 'check' ? (
644
+ <AnimatedPath
645
+ d={CHECK}
646
+ {...s}
647
+ strokeDasharray={[CHECK_LEN, CHECK_LEN]}
648
+ strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [CHECK_LEN, 0] })}
649
+ />
650
+ ) : (
651
+ <>
652
+ <AnimatedPath
653
+ d={CROSS_A}
654
+ {...s}
655
+ strokeDasharray={[CROSS_LEN, CROSS_LEN]}
656
+ strokeDashoffset={draw.interpolate({ inputRange: [0, 0.5], outputRange: [CROSS_LEN, 0], extrapolate: 'clamp' })}
657
+ />
658
+ <AnimatedPath
659
+ d={CROSS_B}
660
+ {...s}
661
+ strokeDasharray={[CROSS_LEN, CROSS_LEN]}
662
+ strokeDashoffset={draw.interpolate({ inputRange: [0.5, 1], outputRange: [CROSS_LEN, 0], extrapolate: 'clamp' })}
663
+ />
664
+ </>
665
+ )}
666
+ </Svg>
667
+ );
668
+ }
669
+
670
+ function Dot({
671
+ item,
672
+ status,
673
+ size,
674
+ tint,
675
+ surface,
676
+ checks,
677
+ still,
678
+ delay,
679
+ }: {
680
+ item: TimelineItem;
681
+ status: TimelineStatus;
682
+ size: number;
683
+ tint: string;
684
+ surface: string;
685
+ checks: boolean;
686
+ still: boolean;
687
+ delay: number;
688
+ }) {
689
+ const { colors, radius, dark } = useKit();
690
+ const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
691
+ const draw = useRef(new Animated.Value(still ? 1 : 0)).current; // JS: dash offsets
692
+ const pulse = useRef(new Animated.Value(0)).current; // native: halo
693
+ const current = status === 'current';
694
+ const marked = status === 'error' || (status === 'done' && checks);
695
+
696
+ useEffect(() => {
697
+ if (still || !marked) {
698
+ draw.setValue(1);
699
+ return;
700
+ }
701
+ draw.setValue(0);
702
+ const a = Animated.timing(draw, {
703
+ toValue: 1,
704
+ duration: status === 'error' ? 420 : 340,
705
+ delay,
706
+ easing: Easing.out(Easing.cubic),
707
+ useNativeDriver: false,
708
+ });
709
+ a.start();
710
+ return () => a.stop();
711
+ }, [still, marked, status, delay, draw]);
712
+
713
+ useEffect(() => {
714
+ if (!current || still) {
715
+ pulse.setValue(0);
716
+ return;
717
+ }
718
+ const loop = Animated.loop(
719
+ Animated.sequence([
720
+ Animated.timing(pulse, { toValue: 1, duration: 1500, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
721
+ Animated.timing(pulse, { toValue: 0, duration: 0, useNativeDriver: true }),
722
+ Animated.delay(500),
723
+ ]),
724
+ );
725
+ const t = setTimeout(() => loop.start(), delay);
726
+ return () => {
727
+ clearTimeout(t);
728
+ loop.stop();
729
+ };
730
+ }, [current, still, delay, pulse]);
731
+
732
+ const tone = status === 'error' ? colors.danger : tint;
733
+ const hollow = alpha(colors.foreground, dark ? 0.24 : 0.2);
734
+ const hasIcon = !!item.icon && !item.avatar;
735
+ const glyph = Math.round(size * 0.5);
736
+
737
+ const halo = current ? (
738
+ <Animated.View
739
+ pointerEvents="none"
740
+ style={[
741
+ StyleSheet.absoluteFill,
742
+ {
743
+ borderRadius: radius.full,
744
+ backgroundColor: alpha(tint, 0.35),
745
+ opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
746
+ transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 2.1] }) }],
747
+ },
748
+ ]}
749
+ />
750
+ ) : null;
751
+
752
+ // Opaque disc so a rail running behind the dot never shows through tinted / dimmed fills.
753
+ const base = <View style={[StyleSheet.absoluteFill, { borderRadius: radius.full, backgroundColor: surface }]} />;
754
+
755
+ // Small check / cross badge on icon dots (avatars only flag failures — a feed is mostly 'done').
756
+ const miniSize = Math.max(12, Math.round(size * 0.42));
757
+ const mini =
758
+ (hasIcon && marked) || (item.avatar && status === 'error') ? (
759
+ <View
760
+ style={[
761
+ styles.mini,
762
+ {
763
+ width: miniSize + 4,
764
+ height: miniSize + 4,
765
+ right: -4,
766
+ bottom: -4,
767
+ borderRadius: radius.full,
768
+ backgroundColor: surface,
769
+ },
770
+ ]}
771
+ >
772
+ <View
773
+ style={{
774
+ width: miniSize,
775
+ height: miniSize,
776
+ borderRadius: radius.full,
777
+ backgroundColor: tone,
778
+ alignItems: 'center',
779
+ justifyContent: 'center',
780
+ }}
781
+ >
782
+ <DrawnMark kind={status === 'error' ? 'cross' : 'check'} color={readableOn(tone)} size={miniSize} draw={draw} />
783
+ </View>
784
+ </View>
785
+ ) : null;
786
+
787
+ if (item.avatar) {
788
+ return (
789
+ <View style={{ width: size, height: size }}>
790
+ {halo}
791
+ {base}
792
+ <View style={{ opacity: status === 'upcoming' ? 0.55 : 1 }}>
793
+ <Avatar src={item.avatar.src} name={item.avatar.name} size={size} surface={surface} />
794
+ </View>
795
+ {mini}
796
+ </View>
797
+ );
798
+ }
799
+
800
+ if (hasIcon) {
801
+ const solid = current;
802
+ const fg = solid ? readableOn(tint) : status === 'upcoming' ? colors.mutedForeground : tone;
803
+ const icon =
804
+ typeof item.icon === 'function' ? item.icon({ color: fg, size: glyph }) : <TimelineIcon name={item.icon as TimelineIconName} color={fg} size={glyph} />;
805
+ return (
806
+ <View style={{ width: size, height: size }}>
807
+ {halo}
808
+ {base}
809
+ <View
810
+ style={[
811
+ styles.dot,
812
+ {
813
+ width: size,
814
+ height: size,
815
+ borderRadius: radius.full,
816
+ backgroundColor: solid
817
+ ? tint
818
+ : status === 'upcoming'
819
+ ? surface
820
+ : alpha(tone, dark ? 0.2 : 0.12),
821
+ borderWidth: status === 'upcoming' ? 1.5 : 0,
822
+ borderColor: hollow,
823
+ },
824
+ solid ? glow(tint) : null,
825
+ ]}
826
+ >
827
+ {solid ? <Sheen id={`sh${uid}`} size={size} /> : null}
828
+ {icon}
829
+ </View>
830
+ {mini}
831
+ </View>
832
+ );
833
+ }
834
+
835
+ // Plain dots.
836
+ const filled = status === 'done' || status === 'error';
837
+ const small = size < 16;
838
+ return (
839
+ <View style={{ width: size, height: size }}>
840
+ {halo}
841
+ <View
842
+ style={[
843
+ styles.dot,
844
+ {
845
+ width: size,
846
+ height: size,
847
+ borderRadius: radius.full,
848
+ backgroundColor: filled ? tone : surface,
849
+ borderWidth: filled ? 0 : current ? 2 : 1.5,
850
+ borderColor: current ? tint : hollow,
851
+ },
852
+ filled ? glow(tone) : null,
853
+ ]}
854
+ >
855
+ {filled ? <Sheen id={`sh${uid}`} size={size} /> : null}
856
+ {filled && marked && !small ? (
857
+ <DrawnMark kind={status === 'error' ? 'cross' : 'check'} color={readableOn(tone)} size={Math.round(size * 0.8)} draw={draw} />
858
+ ) : null}
859
+ {current ? (
860
+ <View style={{ width: size * 0.4, height: size * 0.4, borderRadius: radius.full, backgroundColor: tint }} />
861
+ ) : null}
862
+ </View>
863
+ </View>
864
+ );
865
+ }
866
+
867
+ /** Very soft white top highlight on solid dots (neutral, ≤ 0.12). */
868
+ function Sheen({ id, size }: { id: string; size: number }) {
869
+ return (
870
+ <Svg width={size} height={size} style={StyleSheet.absoluteFill} pointerEvents="none">
871
+ <Defs>
872
+ <LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
873
+ <Stop offset="0" stopColor="#ffffff" stopOpacity={0.12} />
874
+ <Stop offset="0.6" stopColor="#ffffff" stopOpacity={0} />
875
+ </LinearGradient>
876
+ </Defs>
877
+ <Circle cx={size / 2} cy={size / 2} r={size / 2} fill={`url(#${id})`} />
878
+ </Svg>
879
+ );
880
+ }
881
+
882
+ function glow(color: string): ViewStyle {
883
+ return Platform.OS === 'ios'
884
+ ? { shadowColor: color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 5 }
885
+ : Platform.OS === 'android'
886
+ ? { elevation: 2 }
887
+ : {};
888
+ }
889
+
890
+ function srcOf(image: TimelineImageSource) {
891
+ return typeof image === 'string' ? { uri: image, ratio: 16 / 9 } : { uri: image.uri, ratio: image.aspectRatio ?? 16 / 9 };
892
+ }
893
+
894
+ /** One photo at its aspect ratio, two side by side, or one large + two stacked (+N on the last tile). */
895
+ function TimelineGallery({ images, dim }: { images: TimelineImageSource[]; dim: boolean }) {
896
+ const { colors, radius, dark } = useKit();
897
+ const shown = images.slice(0, 3).map(srcOf);
898
+ const extra = images.length - shown.length;
899
+ const frame = [
900
+ styles.gallery,
901
+ { borderRadius: radius.md, borderColor: alpha(colors.foreground, dark ? 0.1 : 0.06), opacity: dim ? 0.6 : 1 },
902
+ ];
903
+ const tile = (i: number, flex: ViewStyle) => (
904
+ <Photo key={i} uri={shown[i].uri} delay={i * 90} style={[flex, { borderRadius: radius.scale(4) }]} more={i === 2 && extra > 0 ? extra : 0} />
905
+ );
906
+
907
+ if (shown.length === 1) {
908
+ return <View style={[frame, { aspectRatio: shown[0].ratio }]}>{tile(0, styles.fill)}</View>;
909
+ }
910
+ if (shown.length === 2) {
911
+ return (
912
+ <View style={[frame, styles.grid, { aspectRatio: 2 }]}>
913
+ {tile(0, styles.flex)}
914
+ {tile(1, styles.flex)}
915
+ </View>
916
+ );
917
+ }
918
+ return (
919
+ <View style={[frame, styles.grid, { aspectRatio: 3 / 2 }]}>
920
+ {tile(0, { flex: 2 })}
921
+ <View style={[styles.flex, styles.stack]}>
922
+ {tile(1, styles.flex)}
923
+ {tile(2, styles.flex)}
924
+ </View>
925
+ </View>
926
+ );
927
+ }
928
+
929
+ function Photo({ uri, delay, style, more }: { uri: string; delay: number; style: StyleProp<ViewStyle>; more: number }) {
930
+ const { colors } = useKit();
931
+ const shown = useRef(new Animated.Value(0)).current;
932
+ return (
933
+ <View style={[styles.photo, { backgroundColor: colors.muted }, style]}>
934
+ <Animated.Image
935
+ source={{ uri }}
936
+ accessibilityIgnoresInvertColors
937
+ onLoad={() => Animated.timing(shown, { toValue: 1, duration: 320, delay, useNativeDriver: true }).start()}
938
+ style={[StyleSheet.absoluteFill, { opacity: shown }]}
939
+ />
940
+ {more > 0 ? (
941
+ <View style={[StyleSheet.absoluteFill, styles.more]}>
942
+ <Text style={styles.moreText}>+{more}</Text>
943
+ </View>
944
+ ) : null}
945
+ </View>
946
+ );
947
+ }
948
+
949
+ function useReduceMotion() {
950
+ const [reduce, setReduce] = useState(false);
951
+ useEffect(() => {
952
+ let alive = true;
953
+ AccessibilityInfo.isReduceMotionEnabled()
954
+ .then((v) => alive && setReduce(v))
955
+ .catch(() => {});
956
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
957
+ return () => {
958
+ alive = false;
959
+ sub.remove();
960
+ };
961
+ }, []);
962
+ return reduce;
963
+ }
964
+
965
+ /** Built-in 24×24 line icons with a 1.8 stroke. */
966
+ export function TimelineIcon({ name, color, size = 18 }: { name: TimelineIconName; color: string; size?: number }) {
967
+ const p = { stroke: color, strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
968
+ const icons: Record<TimelineIconName, React.ReactNode> = {
969
+ cart: (
970
+ <>
971
+ <Path d="M3 4h2.5l2.2 11h10.8l2-8H6.4" {...p} />
972
+ <Circle cx={9.5} cy={19.5} r={1.4} {...p} />
973
+ <Circle cx={17} cy={19.5} r={1.4} {...p} />
974
+ </>
975
+ ),
976
+ package: (
977
+ <>
978
+ <Path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...p} />
979
+ <Path d="M4 7.5l8 4.5 8-4.5M12 12v9M8 5.3l8 4.4" {...p} />
980
+ </>
981
+ ),
982
+ truck: (
983
+ <>
984
+ <Path d="M3 6h11v10H3zM14 9h4l3 3.5V16h-7" {...p} />
985
+ <Circle cx={7} cy={17.5} r={1.8} {...p} />
986
+ <Circle cx={17.5} cy={17.5} r={1.8} {...p} />
987
+ </>
988
+ ),
989
+ home: (
990
+ <>
991
+ <Path d="M4 11l8-7 8 7v9H4z" {...p} />
992
+ <Path d="M10 20v-5h4v5" {...p} />
993
+ </>
994
+ ),
995
+ rocket: (
996
+ <>
997
+ <Path d="M14 4c3 0 6 3 6 6l-6.5 6.5-6-6z" {...p} />
998
+ <Path d="M7.5 10.5L4 11l3-4h4M13.5 16.5L13 20l4-3v-4" {...p} />
999
+ <Circle cx={15} cy={9} r={1.5} {...p} />
1000
+ </>
1001
+ ),
1002
+ bug: (
1003
+ <>
1004
+ <Rect x={7} y={8} width={10} height={12} rx={5} {...p} />
1005
+ <Path d="M9 8a3 3 0 0 1 6 0M12 12v8M3 13h4M17 13h4M4 8l3 2M20 8l-3 2M4 19l3-2M20 19l-3-2" {...p} />
1006
+ </>
1007
+ ),
1008
+ star: <Path d="M12 3.5l2.6 5.4 5.9.8-4.3 4.1 1 5.8L12 16.8l-5.2 2.8 1-5.8-4.3-4.1 5.9-.8z" {...p} />,
1009
+ chat: <Path d="M4 5h16v11H9l-5 4z" {...p} />,
1010
+ tag: (
1011
+ <>
1012
+ <Path d="M3.5 12.5V4h8.5l8.5 8.5-8.5 8.5z" {...p} />
1013
+ <Circle cx={8} cy={8.5} r={1.3} {...p} />
1014
+ </>
1015
+ ),
1016
+ user: (
1017
+ <>
1018
+ <Circle cx={12} cy={8} r={4} {...p} />
1019
+ <Path d="M4 20.5c1.2-3.6 4.3-5.5 8-5.5s6.8 1.9 8 5.5" {...p} />
1020
+ </>
1021
+ ),
1022
+ };
1023
+ return (
1024
+ <Svg width={size} height={size} viewBox="0 0 24 24">
1025
+ {icons[name]}
1026
+ </Svg>
1027
+ );
1028
+ }
1029
+
1030
+ const styles = StyleSheet.create({
1031
+ row: { flexDirection: 'row', gap: 14 },
1032
+ rail: { alignItems: 'center' },
1033
+ line: { width: 2, flex: 1, minHeight: 8 },
1034
+ track: { position: 'absolute', width: 2, borderRadius: 1 },
1035
+ scrollContent: { paddingVertical: 12 },
1036
+ dot: { alignItems: 'center', justifyContent: 'center' },
1037
+ mini: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
1038
+ bodyWrap: { flex: 1, minWidth: 0 },
1039
+ card: { borderWidth: 1 },
1040
+ titleRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
1041
+ compactRow: { alignItems: 'center' },
1042
+ title: { flex: 1, fontWeight: '400', letterSpacing: -0.2 },
1043
+ time: { fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
1044
+ desc: { fontSize: 14, fontWeight: '400', lineHeight: 20, marginTop: 3 },
1045
+ gallery: { marginTop: 12, width: '100%', overflow: 'hidden', borderWidth: 1 },
1046
+ grid: { flexDirection: 'row', gap: 3 },
1047
+ stack: { gap: 3 },
1048
+ flex: { flex: 1 },
1049
+ fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
1050
+ photo: { overflow: 'hidden' },
1051
+ more: { alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.42)' },
1052
+ moreText: { color: '#ffffff', fontSize: 18, letterSpacing: -0.3 },
1053
+ chips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, marginTop: 10 },
1054
+ tag: { paddingHorizontal: 9, paddingVertical: 3 },
1055
+ tagText: { fontSize: 12 },
1056
+ link: { paddingVertical: 3, marginRight: 6 },
1057
+ linkText: { fontSize: 13.5 },
1058
+ content: { marginTop: 12 },
1059
+ });