@expokit/cli 0.2.0 → 0.4.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 (232) hide show
  1. package/README.md +20 -2
  2. package/bin/expo-kit.js +219 -44
  3. package/bin/lib.mjs +110 -1
  4. package/bin/mcp.mjs +48 -19
  5. package/package.json +9 -4
  6. package/registry/_docs/accordion.json +16 -0
  7. package/registry/_docs/action-sheet.json +28 -0
  8. package/registry/_docs/alert.json +28 -0
  9. package/registry/_docs/assistant-app.json +47 -0
  10. package/registry/_docs/avatar.json +25 -1
  11. package/registry/_docs/badge.json +36 -5
  12. package/registry/_docs/bottom-nav.json +30 -6
  13. package/registry/_docs/bottom-sheet.json +28 -0
  14. package/registry/_docs/bullet.json +3 -0
  15. package/registry/_docs/button-3d.json +18 -2
  16. package/registry/_docs/button.json +16 -0
  17. package/registry/_docs/calendar.json +21 -5
  18. package/registry/_docs/card-stack.json +16 -0
  19. package/registry/_docs/card.json +20 -0
  20. package/registry/_docs/carousel.json +20 -0
  21. package/registry/_docs/chat.json +184 -0
  22. package/registry/_docs/checkbox.json +16 -0
  23. package/registry/_docs/checkout.json +10 -1
  24. package/registry/_docs/chip-group.json +16 -0
  25. package/registry/_docs/confetti.json +23 -3
  26. package/registry/_docs/coupon.json +16 -0
  27. package/registry/_docs/cube-pager.json +16 -0
  28. package/registry/_docs/dashboard.json +3 -0
  29. package/registry/_docs/date-picker.json +24 -4
  30. package/registry/_docs/dialog.json +24 -0
  31. package/registry/_docs/donut-shop.json +88 -0
  32. package/registry/_docs/drawer.json +32 -4
  33. package/registry/_docs/fab.json +24 -0
  34. package/registry/_docs/fintech-app.json +49 -0
  35. package/registry/_docs/flip-card.json +16 -0
  36. package/registry/_docs/floating-input.json +25 -5
  37. package/registry/_docs/food-app.json +47 -0
  38. package/registry/_docs/gift-card.json +16 -0
  39. package/registry/_docs/glass-button.json +24 -0
  40. package/registry/_docs/glass-nav-bar.json +24 -0
  41. package/registry/_docs/glass-search-bar.json +24 -0
  42. package/registry/_docs/glass-segmented.json +28 -0
  43. package/registry/_docs/glass-sheet.json +28 -0
  44. package/registry/_docs/glass-tab-bar.json +24 -0
  45. package/registry/_docs/glass.json +17 -1
  46. package/registry/_docs/grain.json +80 -0
  47. package/registry/_docs/inbox.json +3 -0
  48. package/registry/_docs/isometric-bar.json +3 -0
  49. package/registry/_docs/kit.json +3 -0
  50. package/registry/_docs/liquidglass-tab.json +3 -0
  51. package/registry/_docs/list-item.json +16 -0
  52. package/registry/_docs/loyalty-card.json +16 -0
  53. package/registry/_docs/morphing-button-group.json +96 -0
  54. package/registry/_docs/number-ticker.json +16 -0
  55. package/registry/_docs/offers.json +3 -0
  56. package/registry/_docs/onboarding.json +3 -0
  57. package/registry/_docs/orb-reveal.json +132 -0
  58. package/registry/_docs/otp-input.json +28 -4
  59. package/registry/_docs/parallax-layers.json +20 -0
  60. package/registry/_docs/parallax-scroll.json +24 -0
  61. package/registry/_docs/password-input.json +23 -3
  62. package/registry/_docs/payment-card.json +16 -0
  63. package/registry/_docs/pixel-bar.json +3 -0
  64. package/registry/_docs/polar-area.json +3 -0
  65. package/registry/_docs/profile.json +3 -0
  66. package/registry/_docs/progress-steps.json +24 -0
  67. package/registry/_docs/progress.json +20 -0
  68. package/registry/_docs/qr-code-button.json +122 -0
  69. package/registry/_docs/radio-group.json +20 -0
  70. package/registry/_docs/rating.json +24 -0
  71. package/registry/_docs/ring-carousel.json +20 -0
  72. package/registry/_docs/scratch-card.json +20 -0
  73. package/registry/_docs/search-bar.json +23 -3
  74. package/registry/_docs/search-results.json +3 -0
  75. package/registry/_docs/segmented-control.json +20 -0
  76. package/registry/_docs/select.json +33 -8
  77. package/registry/_docs/settings.json +9 -0
  78. package/registry/_docs/sign-in.json +3 -0
  79. package/registry/_docs/skeleton.json +22 -2
  80. package/registry/_docs/slide-to-confirm.json +24 -0
  81. package/registry/_docs/slider.json +27 -7
  82. package/registry/_docs/spin-wheel.json +24 -0
  83. package/registry/_docs/spinner.json +41 -11
  84. package/registry/_docs/stateful-button.json +184 -0
  85. package/registry/_docs/stepper.json +23 -7
  86. package/registry/_docs/swipe-deck.json +16 -0
  87. package/registry/_docs/swipeable-row.json +24 -1
  88. package/registry/_docs/switch.json +20 -0
  89. package/registry/_docs/tabs.json +24 -0
  90. package/registry/_docs/text-animation.json +141 -0
  91. package/registry/_docs/textarea.json +18 -2
  92. package/registry/_docs/ticket-card.json +20 -0
  93. package/registry/_docs/ticket-shape.json +4 -1
  94. package/registry/_docs/tilt-card.json +20 -4
  95. package/registry/_docs/time-picker.json +22 -6
  96. package/registry/_docs/timeline-chart.json +3 -0
  97. package/registry/_docs/timeline.json +18 -2
  98. package/registry/_docs/toast.json +32 -8
  99. package/registry/_docs/treemap.json +3 -0
  100. package/registry/_docs/waterfall.json +3 -0
  101. package/registry/_docs/wheel-list.json +20 -0
  102. package/registry/accordion/files/src/components/ui/accordion.tsx +5 -4
  103. package/registry/accordion/files/src/lib/kit.tsx +8 -5
  104. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +9 -8
  105. package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
  106. package/registry/alert/files/src/components/ui/alert.tsx +7 -4
  107. package/registry/alert/files/src/components/ui/button.tsx +4 -3
  108. package/registry/alert/files/src/lib/kit.tsx +8 -5
  109. package/registry/assistant-app.json +49 -0
  110. package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
  111. package/registry/avatar/files/src/lib/kit.tsx +8 -5
  112. package/registry/badge/files/src/components/ui/badge.tsx +345 -21
  113. package/registry/badge/files/src/lib/kit.tsx +8 -5
  114. package/registry/badge.json +1 -1
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +14 -7
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
  117. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
  118. package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
  119. package/registry/button/files/src/components/ui/button.tsx +4 -3
  120. package/registry/button/files/src/lib/kit.tsx +8 -5
  121. package/registry/button-3d/files/src/components/ui/button-3d.tsx +3 -2
  122. package/registry/button-3d/files/src/lib/kit.tsx +8 -5
  123. package/registry/calendar/files/src/components/ui/calendar.tsx +9 -5
  124. package/registry/calendar/files/src/lib/kit.tsx +8 -5
  125. package/registry/card/files/src/components/ui/card.tsx +8 -6
  126. package/registry/card/files/src/lib/kit.tsx +8 -5
  127. package/registry/chat.json +18 -0
  128. package/registry/checkbox/files/src/components/ui/checkbox.tsx +6 -5
  129. package/registry/checkbox/files/src/lib/kit.tsx +8 -5
  130. package/registry/chip-group/files/src/components/ui/chip-group.tsx +4 -2
  131. package/registry/chip-group/files/src/lib/kit.tsx +8 -5
  132. package/registry/confetti/files/src/lib/kit.tsx +8 -5
  133. package/registry/coupon/files/src/components/ui/coupon.tsx +23 -20
  134. package/registry/coupon/files/src/components/ui/flip-card.tsx +1 -1
  135. package/registry/coupon/files/src/components/ui/tilt-card.tsx +2 -2
  136. package/registry/coupon/files/src/lib/kit.tsx +8 -5
  137. package/registry/date-picker/files/src/components/ui/calendar.tsx +9 -5
  138. package/registry/date-picker/files/src/components/ui/date-picker.tsx +2 -2
  139. package/registry/date-picker/files/src/lib/kit.tsx +8 -5
  140. package/registry/dialog/files/src/components/ui/dialog.tsx +12 -6
  141. package/registry/dialog/files/src/lib/kit.tsx +8 -5
  142. package/registry/donut-shop.json +18 -0
  143. package/registry/drawer/files/src/components/ui/drawer.tsx +25 -19
  144. package/registry/drawer/files/src/lib/kit.tsx +8 -5
  145. package/registry/fintech-app.json +57 -0
  146. package/registry/flip-card/files/src/components/ui/flip-card.tsx +1 -1
  147. package/registry/flip-card/files/src/lib/kit.tsx +8 -5
  148. package/registry/floating-input/files/src/components/ui/floating-input.tsx +16 -6
  149. package/registry/floating-input/files/src/lib/kit.tsx +8 -5
  150. package/registry/food-app.json +55 -0
  151. package/registry/grain/files/src/components/ui/grain.tsx +113 -0
  152. package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
  153. package/registry/grain.json +17 -0
  154. package/registry/kit/files/src/lib/kit.tsx +8 -5
  155. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
  156. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
  157. package/registry/list-item/files/src/components/ui/list-item.tsx +20 -16
  158. package/registry/list-item/files/src/lib/kit.tsx +8 -5
  159. package/registry/morphing-button-group.json +18 -0
  160. package/registry/orb-reveal.json +20 -0
  161. package/registry/otp-input/files/src/components/ui/otp-input.tsx +8 -6
  162. package/registry/otp-input/files/src/lib/kit.tsx +8 -5
  163. package/registry/password-input/files/src/components/ui/floating-input.tsx +16 -6
  164. package/registry/password-input/files/src/components/ui/password-input.tsx +2 -2
  165. package/registry/password-input/files/src/lib/kit.tsx +8 -5
  166. package/registry/payment-card/files/src/components/ui/flip-card.tsx +1 -1
  167. package/registry/payment-card/files/src/components/ui/payment-card.tsx +27 -14
  168. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +2 -2
  169. package/registry/payment-card/files/src/lib/kit.tsx +8 -5
  170. package/registry/progress/files/src/components/ui/progress.tsx +8 -5
  171. package/registry/progress/files/src/lib/kit.tsx +8 -5
  172. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +15 -11
  173. package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
  174. package/registry/qr-code-button.json +18 -0
  175. package/registry/radio-group/files/src/components/ui/radio-group.tsx +10 -9
  176. package/registry/radio-group/files/src/lib/kit.tsx +8 -5
  177. package/registry/search-bar/files/src/components/ui/search-bar.tsx +13 -11
  178. package/registry/search-bar/files/src/lib/kit.tsx +8 -5
  179. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +4 -2
  180. package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
  181. package/registry/select/files/src/components/ui/select.tsx +3 -3
  182. package/registry/select/files/src/lib/kit.tsx +8 -5
  183. package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
  184. package/registry/skeleton/files/src/lib/kit.tsx +8 -5
  185. package/registry/slider/files/src/components/ui/slider.tsx +5 -4
  186. package/registry/slider/files/src/lib/kit.tsx +8 -5
  187. package/registry/spinner/files/src/components/ui/spinner.tsx +452 -34
  188. package/registry/spinner/files/src/lib/kit.tsx +8 -5
  189. package/registry/spinner.json +1 -1
  190. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  191. package/registry/{pagination-dots → stateful-button}/files/src/lib/kit.tsx +8 -5
  192. package/registry/stateful-button.json +18 -0
  193. package/registry/stepper/files/src/components/ui/stepper.tsx +8 -4
  194. package/registry/stepper/files/src/lib/kit.tsx +8 -5
  195. package/registry/switch/files/src/components/ui/switch.tsx +4 -3
  196. package/registry/switch/files/src/lib/kit.tsx +8 -5
  197. package/registry/tabs/files/src/components/ui/tabs.tsx +10 -8
  198. package/registry/tabs/files/src/lib/kit.tsx +8 -5
  199. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  200. package/registry/text-animation/files/src/lib/kit.tsx +222 -0
  201. package/registry/text-animation.json +15 -0
  202. package/registry/textarea/files/src/components/ui/textarea.tsx +11 -9
  203. package/registry/textarea/files/src/lib/kit.tsx +8 -5
  204. package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
  205. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +2 -2
  206. package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
  207. package/registry/time-picker/files/src/components/ui/time-picker.tsx +2 -2
  208. package/registry/time-picker/files/src/lib/kit.tsx +8 -5
  209. package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
  210. package/registry/timeline/files/src/components/ui/timeline.tsx +12 -10
  211. package/registry/timeline/files/src/lib/kit.tsx +8 -5
  212. package/registry/toast/files/src/components/ui/button.tsx +4 -3
  213. package/registry/toast/files/src/components/ui/toast.tsx +13 -11
  214. package/registry/toast/files/src/lib/kit.tsx +8 -5
  215. package/schema/components.schema.json +1 -1
  216. package/registry/_docs/color-swatch.json +0 -66
  217. package/registry/_docs/kpi-card.json +0 -11
  218. package/registry/_docs/like-button.json +0 -72
  219. package/registry/_docs/morph-button.json +0 -108
  220. package/registry/_docs/pagination-dots.json +0 -88
  221. package/registry/_docs/shimmer-button.json +0 -78
  222. package/registry/_docs/tag-input.json +0 -128
  223. package/registry/color-swatch.json +0 -17
  224. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
  225. package/registry/kpi-card.json +0 -14
  226. package/registry/like-button.json +0 -18
  227. package/registry/morph-button.json +0 -18
  228. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
  229. package/registry/pagination-dots.json +0 -15
  230. package/registry/shimmer-button.json +0 -17
  231. package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
  232. package/registry/tag-input.json +0 -17
@@ -0,0 +1,650 @@
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 TextStyle,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+
15
+ import { useKit } from '@/lib/kit';
16
+
17
+ export type AnimatedTextVariant = 'reveal' | 'shimmer' | 'cascade' | 'scramble' | 'typewriter';
18
+ /** 'mount' animates once when shown; 'change' also replays whenever the text changes. */
19
+ export type AnimatedTextTrigger = 'mount' | 'change';
20
+
21
+ export interface AnimatedTextProps {
22
+ /** The text to animate. */
23
+ children: string;
24
+ variant?: AnimatedTextVariant;
25
+ /** Default 'change' for cascade and typewriter (typewriter keeps typing when the new text extends the old), 'mount' otherwise. */
26
+ trigger?: AnimatedTextTrigger;
27
+ /** Wait before the first animation, in ms. */
28
+ delay?: number;
29
+ /** Time between words (reveal) or letters (everything else), in ms. Shimmer: ms of sweep per letter. */
30
+ stagger?: number;
31
+ /** Speed multiplier: 2 plays twice as fast. */
32
+ speed?: number;
33
+ /** Text colour (default: theme foreground). */
34
+ color?: string;
35
+ /** Blinking caret for `typewriter` (default true). */
36
+ caret?: boolean;
37
+ /** Change it to replay the animation. */
38
+ replayKey?: string | number;
39
+ /** Called when an animation finishes (never for the looping shimmer). */
40
+ onComplete?: () => void;
41
+ style?: StyleProp<ViewStyle>;
42
+ /** fontSize, fontWeight, lineHeight, letterSpacing, textAlign… applied to every letter. */
43
+ textStyle?: StyleProp<TextStyle>;
44
+ }
45
+
46
+ const STAGGER: Record<AnimatedTextVariant, number> = { reveal: 70, shimmer: 80, cascade: 28, scramble: 45, typewriter: 32 };
47
+ /** Clock value that puts every letter at rest, past the end of any timeline. */
48
+ const END = 1e6;
49
+
50
+ // Scripts whose letters join or reorder (Arabic, Hebrew, Indic, Thai, Lao, Tibetan, Myanmar, Khmer):
51
+ // splitting them into separate views breaks shaping, so they animate a word at a time.
52
+ const CONNECTED = /[֐-ࣿऀ-෿฀-໿ༀ-࿿က-႟ក-៿יִ-﷿ﹰ-]/;
53
+ const RTL = /[֐-ࣿיִ-﷿ﹰ-]/;
54
+ // Combining marks, emoji joiners / variation selectors and skin tones: keep those words whole too.
55
+ const CLUSTER = /[̀-ͯ᪰-᫿⃐-⃿‍︎️]|\uD83C[\uDFFB-\uDFFF]/;
56
+
57
+ interface Unit {
58
+ ch: string;
59
+ /** Index across the whole text, for staggering. */
60
+ i: number;
61
+ /** A whole word of a connected script (animated as one piece). */
62
+ whole: boolean;
63
+ }
64
+ interface Word {
65
+ text: string;
66
+ units: Unit[];
67
+ w: number;
68
+ }
69
+ interface Layout {
70
+ lines: Word[][];
71
+ count: number;
72
+ words: number;
73
+ rtl: boolean;
74
+ }
75
+
76
+ function tokenize(text: string): Layout {
77
+ let i = 0;
78
+ let w = 0;
79
+ const lines = text.split('\n').map((line) =>
80
+ line
81
+ .split(/\s+/)
82
+ .filter(Boolean)
83
+ .map((word) => {
84
+ const whole = CONNECTED.test(word) || CLUSTER.test(word);
85
+ const parts = whole ? [word] : Array.from(word);
86
+ return { text: word, units: parts.map((ch) => ({ ch, i: i++, whole })), w: w++ };
87
+ }),
88
+ );
89
+ return { lines, count: i, words: w, rtl: RTL.test(text) };
90
+ }
91
+
92
+ /** End index of the next typed step: one letter (with its marks / emoji joiners), or a whole word in connected scripts. */
93
+ function nextStop(text: string, from: number) {
94
+ if (CONNECTED.test(text)) {
95
+ const m = /^\s*\S+/.exec(text.slice(from));
96
+ return from + (m ? m[0].length : text.length - from);
97
+ }
98
+ let i = from;
99
+ const step = () => {
100
+ const code = text.codePointAt(i) ?? 0;
101
+ i += code > 0xffff ? 2 : 1;
102
+ };
103
+ step();
104
+ for (;;) {
105
+ const rest = text.slice(i, i + 2);
106
+ if (/^[̀-ͯ᪰-᫿⃐-⃿︎️]/.test(rest) || /^\uD83C[\uDFFB-\uDFFF]/.test(rest)) step();
107
+ else if (rest.startsWith('‍')) {
108
+ i += 1;
109
+ if (i < text.length) step();
110
+ } else break;
111
+ }
112
+ return Math.min(i, text.length);
113
+ }
114
+
115
+ const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
116
+ const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
117
+ /** A soft, critically damped settle (no overshoot), sampled as a curve so it runs on the native driver. */
118
+ const soft = (t: number) => 1 - Math.exp(-7 * t) * (1 + 7 * t);
119
+ /** A firmer settle, also without overshoot. */
120
+ const settle = (t: number) => 1 - Math.exp(-8 * t) * (1 + 8 * t);
121
+
122
+ /**
123
+ * Maps a millisecond clock onto `from → to` between `start` and `start + dur` through `ease`.
124
+ * Pre-sampled, because the native driver doesn't support `easing` on interpolations.
125
+ */
126
+ function track(clock: Animated.Value, start: number, dur: number, from: number, to: number, ease: (t: number) => number, steps = 14) {
127
+ const inputRange: number[] = [];
128
+ const outputRange: number[] = [];
129
+ for (let k = 0; k <= steps; k++) {
130
+ const t = k / steps;
131
+ inputRange.push(start + dur * t);
132
+ outputRange.push(k === steps ? to : from + (to - from) * ease(t));
133
+ }
134
+ return clock.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
135
+ }
136
+
137
+ function useReduceMotion() {
138
+ const [reduce, setReduce] = useState(false);
139
+ useEffect(() => {
140
+ let alive = true;
141
+ AccessibilityInfo.isReduceMotionEnabled()
142
+ .then((r) => alive && setReduce(r))
143
+ .catch(() => {});
144
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
145
+ return () => {
146
+ alive = false;
147
+ sub.remove();
148
+ };
149
+ }, []);
150
+ return reduce;
151
+ }
152
+
153
+ interface Ctx {
154
+ glyph: StyleProp<TextStyle>;
155
+ lh: number;
156
+ space: number;
157
+ justify: ViewStyle['justifyContent'];
158
+ reduce: boolean;
159
+ /** Latest timing props, read when an animation starts (so re-renders never restart one). */
160
+ live: React.RefObject<{ delay: number; speed: number; stagger: number }>;
161
+ done: () => void;
162
+ }
163
+
164
+ /** Words laid out in a wrapping row; each line of the text starts a new row. */
165
+ function Flow({ layout, ctx, children }: { layout: Layout; ctx: Ctx; children: (word: Word) => React.ReactNode }) {
166
+ return (
167
+ <View
168
+ style={[
169
+ styles.flow,
170
+ { columnGap: ctx.space, justifyContent: ctx.justify, flexDirection: layout.rtl ? 'row-reverse' : 'row' },
171
+ ]}
172
+ >
173
+ {layout.lines.map((line, li) => (
174
+ <React.Fragment key={li}>
175
+ {li > 0 ? <View style={styles.break} /> : null}
176
+ {line.map(children)}
177
+ </React.Fragment>
178
+ ))}
179
+ </View>
180
+ );
181
+ }
182
+
183
+ /** Words rise and fade in on a soft spring, one after another. */
184
+ function Reveal({ layout, ctx }: { layout: Layout; ctx: Ctx }) {
185
+ const { reduce, live, done, lh } = ctx;
186
+ const [clock] = useState(() => new Animated.Value(0));
187
+ const words = useRef(layout.words);
188
+ words.current = layout.words;
189
+ const played = useRef(false);
190
+
191
+ useEffect(() => {
192
+ if (played.current) return;
193
+ if (reduce) {
194
+ played.current = true;
195
+ clock.setValue(END);
196
+ done();
197
+ return;
198
+ }
199
+ const { delay, speed, stagger } = live.current;
200
+ const total = Math.max(0, words.current - 1) * stagger + 720;
201
+ const anim = Animated.timing(clock, { toValue: total, duration: total / speed, delay, easing: Easing.linear, useNativeDriver: true });
202
+ anim.start(({ finished }) => {
203
+ if (!finished) return;
204
+ played.current = true;
205
+ clock.setValue(END);
206
+ done();
207
+ });
208
+ return () => anim.stop();
209
+ }, [reduce, clock, live, done]);
210
+
211
+ const stagger = live.current.stagger;
212
+ const anims = useMemo(
213
+ () =>
214
+ layout.lines.flat().map((word) => {
215
+ const start = word.w * stagger;
216
+ return {
217
+ opacity: track(clock, start, 400, 0, 1, easeOut),
218
+ translateY: track(clock, start, 720, lh * 0.6, 0, soft, 18),
219
+ };
220
+ }),
221
+ [layout, clock, lh, stagger],
222
+ );
223
+
224
+ return (
225
+ <Flow layout={layout} ctx={ctx}>
226
+ {(word) => {
227
+ const a = anims[word.w];
228
+ return (
229
+ <Animated.Text key={word.w} style={[ctx.glyph, { opacity: a.opacity, transform: [{ translateY: a.translateY }] }]}>
230
+ {word.text}
231
+ </Animated.Text>
232
+ );
233
+ }}
234
+ </Flow>
235
+ );
236
+ }
237
+
238
+ /** A band of light sweeps across the letters on a loop — one native-driven clock, a per-letter opacity wave. */
239
+ function Shimmer({ layout, ctx }: { layout: Layout; ctx: Ctx }) {
240
+ const { reduce, live } = ctx;
241
+ const [clock] = useState(() => new Animated.Value(0));
242
+ const n = layout.count;
243
+ const stagger = live.current.stagger;
244
+ const sweep = Math.min(2400, Math.max(900, n * stagger));
245
+ const period = sweep + 650;
246
+
247
+ useEffect(() => {
248
+ if (reduce) return;
249
+ clock.setValue(0);
250
+ const loop = Animated.loop(
251
+ Animated.timing(clock, { toValue: period, duration: period / live.current.speed, easing: Easing.linear, useNativeDriver: true }),
252
+ );
253
+ const id = setTimeout(() => loop.start(), live.current.delay);
254
+ return () => {
255
+ clearTimeout(id);
256
+ loop.stop();
257
+ };
258
+ }, [reduce, clock, period, live]);
259
+
260
+ const opacities = useMemo(() => {
261
+ const band = Math.min(0.5, Math.max(0.16, 2.6 / Math.max(1, n)));
262
+ const base = 0.36;
263
+ const S = 36;
264
+ return Array.from({ length: n }, (_, i) => {
265
+ const x = n > 1 ? i / (n - 1) : 0.5;
266
+ const inputRange: number[] = [];
267
+ const outputRange: number[] = [];
268
+ for (let k = 0; k <= S; k++) {
269
+ const p = -band + (k / S) * (1 + 2 * band);
270
+ const d = (p - x) / band;
271
+ inputRange.push((sweep * k) / S);
272
+ outputRange.push(Math.abs(d) < 1 ? base + (1 - base) * (0.5 + 0.5 * Math.cos(Math.PI * d)) : base);
273
+ }
274
+ inputRange.push(period);
275
+ outputRange.push(base);
276
+ return clock.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
277
+ });
278
+ }, [n, sweep, period, clock]);
279
+
280
+ return (
281
+ <Flow layout={layout} ctx={ctx}>
282
+ {(word) => (
283
+ <View key={word.w} style={styles.word}>
284
+ {word.units.map((u) => (
285
+ <Animated.Text key={u.i} style={[ctx.glyph, !reduce && { opacity: opacities[u.i] }]}>
286
+ {u.ch}
287
+ </Animated.Text>
288
+ ))}
289
+ </View>
290
+ )}
291
+ </Flow>
292
+ );
293
+ }
294
+
295
+ type RollAnim = { opacity: Animated.AnimatedInterpolation<number>; translateY: Animated.AnimatedInterpolation<number> };
296
+
297
+ /** One word of a cascade: every letter clipped to a single line so it can roll through it. */
298
+ function RollWord({ word, anims, glyph, lh }: { word: Word; anims: RollAnim[]; glyph: StyleProp<TextStyle>; lh: number }) {
299
+ return (
300
+ <View style={styles.word}>
301
+ {word.units.map((u) => (
302
+ <View key={u.i} style={{ height: lh, overflow: 'hidden' }}>
303
+ <Animated.Text style={[glyph, { opacity: anims[u.i].opacity, transform: [{ translateY: anims[u.i].translateY }] }]}>
304
+ {u.ch}
305
+ </Animated.Text>
306
+ </View>
307
+ ))}
308
+ </View>
309
+ );
310
+ }
311
+
312
+ interface Roll {
313
+ text: string;
314
+ prev: string | null;
315
+ /** Width the previous text was laid out at, so it wraps the same while it leaves. */
316
+ prevWidth: number;
317
+ animated: boolean;
318
+ id: number;
319
+ }
320
+
321
+ /** Old letters roll up and out while the new text's letters roll in from below, left to right. */
322
+ function Cascade({ text, ctx, changes }: { text: string; ctx: Ctx; changes: boolean }) {
323
+ const { reduce, live, done, lh, glyph } = ctx;
324
+ const width = useRef(0);
325
+ const [roll, setRoll] = useState<Roll>({ text, prev: null, prevWidth: 0, animated: true, id: 0 });
326
+ if (roll.text !== text) {
327
+ setRoll({ text, prev: changes ? roll.text : null, prevWidth: width.current, animated: changes, id: roll.id + 1 });
328
+ }
329
+
330
+ // A fresh clock per roll, created at 0 so the first frame of a change is already in place.
331
+ const clockRef = useRef<{ id: number; v: Animated.Value } | null>(null);
332
+ if (!clockRef.current || clockRef.current.id !== roll.id) {
333
+ clockRef.current = { id: roll.id, v: new Animated.Value(roll.animated ? 0 : END) };
334
+ }
335
+ const clock = clockRef.current.v;
336
+
337
+ const layout = useMemo(() => tokenize(roll.text), [roll.text]);
338
+ const prevLayout = useMemo(() => (roll.prev ? tokenize(roll.prev) : null), [roll.prev]);
339
+ const counts = useRef({ next: 0, prev: 0, animated: true, text });
340
+ counts.current = { next: layout.count, prev: prevLayout?.count ?? 0, animated: roll.animated, text: roll.text };
341
+
342
+ const id = roll.id;
343
+ const played = useRef(-1);
344
+ useEffect(() => {
345
+ if (played.current === id) return;
346
+ const { next, prev, animated } = counts.current;
347
+ if (id > 0 && animated) AccessibilityInfo.announceForAccessibility(counts.current.text);
348
+ if (!animated || reduce) {
349
+ played.current = id;
350
+ clock.setValue(END);
351
+ if (animated) done();
352
+ return;
353
+ }
354
+ const { delay, speed, stagger } = live.current;
355
+ const total = Math.max(next, prev) * stagger + 90 + 650;
356
+ const anim = Animated.timing(clock, {
357
+ toValue: total,
358
+ duration: total / speed,
359
+ delay: id === 0 ? delay : 0,
360
+ easing: Easing.linear,
361
+ useNativeDriver: true,
362
+ });
363
+ anim.start(({ finished }) => {
364
+ if (!finished) return;
365
+ played.current = id;
366
+ clock.setValue(END);
367
+ setRoll((r) => (r.id === id ? { ...r, prev: null } : r));
368
+ done();
369
+ });
370
+ return () => anim.stop();
371
+ }, [id, reduce, clock, live, done]);
372
+
373
+ const stagger = live.current.stagger;
374
+ const enter = useMemo(
375
+ () =>
376
+ Array.from({ length: layout.count }, (_, i) => ({
377
+ opacity: track(clock, i * stagger + 90, 260, 0, 1, easeOut),
378
+ translateY: track(clock, i * stagger + 90, 650, lh, 0, settle, 18),
379
+ })),
380
+ [layout, clock, lh, stagger],
381
+ );
382
+ const exit = useMemo(
383
+ () =>
384
+ Array.from({ length: prevLayout?.count ?? 0 }, (_, i) => ({
385
+ opacity: track(clock, i * stagger + 160, 260, 1, 0, easeOut),
386
+ translateY: track(clock, i * stagger, 420, 0, -lh, easeInOut),
387
+ })),
388
+ [prevLayout, clock, lh, stagger],
389
+ );
390
+
391
+
392
+ const pw = roll.prevWidth;
393
+ const end = layout.rtl ? ctx.justify === 'flex-start' : ctx.justify === 'flex-end';
394
+ const overlayPos: ViewStyle =
395
+ ctx.justify === 'center' && pw > 0
396
+ ? { left: '50%', marginLeft: -pw / 2, width: pw }
397
+ : end
398
+ ? { right: 0, width: pw || undefined }
399
+ : { left: 0, width: pw || undefined };
400
+
401
+ return (
402
+ <View onLayout={(e: LayoutChangeEvent) => (width.current = e.nativeEvent.layout.width)}>
403
+ <Flow layout={layout} ctx={ctx}>
404
+ {(word) => <RollWord key={word.w} word={word} anims={enter} glyph={glyph} lh={lh} />}
405
+ </Flow>
406
+ {prevLayout && !reduce ? (
407
+ <View pointerEvents="none" style={[styles.overlay, overlayPos]}>
408
+ <Flow layout={prevLayout} ctx={ctx}>
409
+ {(word) => <RollWord key={word.w} word={word} anims={exit} glyph={glyph} lh={lh} />}
410
+ </Flow>
411
+ </View>
412
+ ) : null}
413
+ </View>
414
+ );
415
+ }
416
+
417
+ const POOLS = {
418
+ upper: 'ABCDEFGHJKLMNPQRSTUVWXYZ',
419
+ lower: 'abcdefghjkmnpqrstuvwxyz',
420
+ digit: '0123456789',
421
+ other: '#%&*+=?@$<>/',
422
+ };
423
+ function poolFor(ch: string) {
424
+ if (/[0-9]/.test(ch)) return POOLS.digit;
425
+ if (/[A-Z]/.test(ch)) return POOLS.upper;
426
+ if (/[a-z]/.test(ch)) return POOLS.lower;
427
+ return POOLS.other;
428
+ }
429
+
430
+ /** Each letter cycles through random glyphs, then settles in place, left to right. */
431
+ function Scramble({ text, layout, ctx }: { text: string; layout: Layout; ctx: Ctx }) {
432
+ const { reduce, live, done, glyph } = ctx;
433
+ const units = useMemo(() => layout.lines.flat().flatMap((w) => w.units), [layout]);
434
+ // Glyph per letter: null = settled, '' = hidden (whole words of connected scripts fade in instead).
435
+ const [frame, setFrame] = useState<{ text: string; glyphs: (string | null)[] } | null>(() => ({
436
+ text,
437
+ glyphs: units.map((u, k) => (u.whole ? '' : poolFor(u.ch)[(k * 7 + 3) % poolFor(u.ch).length])),
438
+ }));
439
+ const latest = useRef({ text, units });
440
+ latest.current = { text, units };
441
+ const played = useRef(false);
442
+
443
+ useEffect(() => {
444
+ if (played.current) return;
445
+ if (reduce) {
446
+ played.current = true;
447
+ done();
448
+ return;
449
+ }
450
+ const { delay, speed, stagger } = live.current;
451
+ const lead = 320;
452
+ const start = Date.now() + delay;
453
+ const id = setInterval(() => {
454
+ const t = (Date.now() - start) * speed;
455
+ if (t < 0) return;
456
+ const { text: now, units: list } = latest.current;
457
+ let pending = false;
458
+ const glyphs = list.map((u, k) => {
459
+ if (t >= lead + k * stagger) return null;
460
+ pending = true;
461
+ if (u.whole) return '';
462
+ const pool = poolFor(u.ch);
463
+ return pool[Math.floor(Math.random() * pool.length)];
464
+ });
465
+ setFrame(pending ? { text: now, glyphs } : null);
466
+ if (!pending) {
467
+ played.current = true;
468
+ clearInterval(id);
469
+ done();
470
+ }
471
+ }, 50);
472
+ return () => clearInterval(id);
473
+ }, [reduce, live, done]);
474
+
475
+ const active = !reduce && frame && frame.text === text ? frame.glyphs : null;
476
+
477
+ return (
478
+ <Flow layout={layout} ctx={ctx}>
479
+ {(word) => (
480
+ <View key={word.w} style={styles.word}>
481
+ {word.units.map((u) => {
482
+ const g = active ? active[u.i] : null;
483
+ if (g === null || g === undefined) {
484
+ return (
485
+ <Text key={u.i} style={[glyph, styles.tabular]}>
486
+ {u.ch}
487
+ </Text>
488
+ );
489
+ }
490
+ // The real letter holds the cell's width (invisible); the random glyph sits centred over it.
491
+ return (
492
+ <View key={u.i}>
493
+ <Text style={[glyph, styles.tabular, styles.hidden]}>{u.ch}</Text>
494
+ {g ? <Text style={[glyph, styles.tabular, styles.scrambled]}>{g}</Text> : null}
495
+ </View>
496
+ );
497
+ })}
498
+ </View>
499
+ )}
500
+ </Flow>
501
+ );
502
+ }
503
+
504
+ const CARET = '▍';
505
+
506
+ /**
507
+ * Letters appear one by one behind a caret. If the new text extends the old (streaming), typing
508
+ * continues from where it is; otherwise it starts over (`restart`) or snaps to the new text.
509
+ */
510
+ function Typewriter({ text, ctx, restart, caret, caretColor }: { text: string; ctx: Ctx; restart: boolean; caret: boolean; caretColor: string }) {
511
+ const { reduce, live, done, glyph } = ctx;
512
+ const [first] = useState(text);
513
+ const [shown, setShown] = useState('');
514
+ const animate = !reduce;
515
+ const visible = !animate ? text : restart ? (text.startsWith(shown) ? shown : '') : text === first ? shown : text;
516
+ const typing = visible.length < text.length;
517
+ const started = useRef(false);
518
+
519
+ useEffect(() => {
520
+ if (!typing) return;
521
+ const { delay, speed, stagger } = live.current;
522
+ const last = visible.slice(-1);
523
+ // Small human rhythm: a beat after sentences and commas, a little jitter between letters.
524
+ let wait = stagger * (/[.!?…]/.test(last) ? 7 : /[,;:]/.test(last) ? 3.5 : 1) * (0.7 + Math.random() * 0.6);
525
+ if (!started.current) wait += delay * speed;
526
+ const id = setTimeout(() => {
527
+ started.current = true;
528
+ setShown(text.slice(0, nextStop(text, visible.length)));
529
+ }, wait / speed);
530
+ return () => clearTimeout(id);
531
+ }, [typing, text, visible, live]);
532
+
533
+ // Finished typing: report it and (after the first text) announce the new text once it has settled.
534
+ const complete = !typing;
535
+ useEffect(() => {
536
+ if (!complete) return;
537
+ done();
538
+ if (text === first) return;
539
+ const id = setTimeout(() => AccessibilityInfo.announceForAccessibility(text), 600);
540
+ return () => clearTimeout(id);
541
+ }, [complete, text, first, done]);
542
+
543
+ const [blink, setBlink] = useState(true);
544
+ const showCaret = caret && animate;
545
+ useEffect(() => {
546
+ if (typing || !showCaret) return;
547
+ const id = setInterval(() => setBlink((b) => !b), 530);
548
+ return () => clearInterval(id);
549
+ }, [typing, showCaret]);
550
+
551
+ return (
552
+ <View>
553
+ {/* Reserves the final size so surrounding layout doesn't jump while typing. */}
554
+ <Text style={[glyph, styles.ghost]}>
555
+ {text}
556
+ {showCaret ? CARET : null}
557
+ </Text>
558
+ <Text style={[glyph, StyleSheet.absoluteFill]}>
559
+ {visible}
560
+ {showCaret ? <Text style={{ color: typing || blink ? caretColor : 'transparent' }}>{CARET}</Text> : null}
561
+ </Text>
562
+ </View>
563
+ );
564
+ }
565
+
566
+ /**
567
+ * Text that animates in five ways — words rising on a spring, a looping shimmer, letters rolling
568
+ * between values, a glyph scramble, or a typewriter with a caret. All motion is on the native driver
569
+ * (scramble and typewriter use a JS timer), and screen readers get the whole string as one element.
570
+ */
571
+ export function AnimatedText({
572
+ children,
573
+ variant = 'reveal',
574
+ trigger,
575
+ delay = 0,
576
+ stagger,
577
+ speed = 1,
578
+ color,
579
+ caret = true,
580
+ replayKey,
581
+ onComplete,
582
+ style,
583
+ textStyle,
584
+ }: AnimatedTextProps) {
585
+ const { colors, font, text: type } = useKit();
586
+ const reduce = useReduceMotion();
587
+ const text = String(children ?? '');
588
+ const mode = trigger ?? (variant === 'cascade' || variant === 'typewriter' ? 'change' : 'mount');
589
+
590
+ const flat = StyleSheet.flatten(textStyle) ?? {};
591
+ const fontSize = flat.fontSize ?? type.md.fontSize ?? 16;
592
+ const lh = flat.lineHeight ?? Math.round(fontSize * 1.3);
593
+ const tint = color ?? (typeof flat.color === 'string' ? flat.color : colors.foreground);
594
+ const justify: ViewStyle['justifyContent'] =
595
+ flat.textAlign === 'center' ? 'center' : flat.textAlign === 'right' ? 'flex-end' : 'flex-start';
596
+
597
+ const live = useRef({ delay, speed: Math.max(0.1, speed), stagger: stagger ?? STAGGER[variant] });
598
+ live.current = { delay, speed: Math.max(0.1, speed), stagger: stagger ?? STAGGER[variant] };
599
+ const complete = useRef(onComplete);
600
+ complete.current = onComplete;
601
+ const [done] = useState(() => () => complete.current?.());
602
+
603
+ const layout = useMemo(() => tokenize(text), [text]);
604
+ const ctx: Ctx = {
605
+ glyph: [styles.glyph, { fontSize, fontFamily: font.family }, textStyle, { lineHeight: lh, color: tint }],
606
+ lh,
607
+ space: fontSize * 0.28,
608
+ justify,
609
+ reduce,
610
+ live,
611
+ done,
612
+ };
613
+
614
+ const replay = String(replayKey ?? '');
615
+ let body: React.ReactNode;
616
+ if (variant === 'shimmer') body = <Shimmer key={replay} layout={layout} ctx={ctx} />;
617
+ else if (variant === 'cascade') body = <Cascade key={replay} text={text} ctx={ctx} changes={mode === 'change'} />;
618
+ else if (variant === 'typewriter')
619
+ body = <Typewriter key={replay} text={text} ctx={ctx} restart={mode === 'change'} caret={caret} caretColor={tint} />;
620
+ else {
621
+ // Remounting replays: on `replayKey`, and on every text change when trigger is 'change'.
622
+ const key = `${replay}|${mode === 'change' ? text : ''}`;
623
+ body =
624
+ variant === 'scramble' ? (
625
+ <Scramble key={key} text={text} layout={layout} ctx={ctx} />
626
+ ) : (
627
+ <Reveal key={key} layout={layout} ctx={ctx} />
628
+ );
629
+ }
630
+
631
+ return (
632
+ <View accessible accessibilityRole="text" accessibilityLabel={text} style={style}>
633
+ <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
634
+ {body}
635
+ </View>
636
+ </View>
637
+ );
638
+ }
639
+
640
+ const styles = StyleSheet.create({
641
+ flow: { flexWrap: 'wrap', alignItems: 'flex-start' },
642
+ break: { flexBasis: '100%', height: 0 },
643
+ word: { flexDirection: 'row' },
644
+ glyph: { includeFontPadding: false },
645
+ tabular: { fontVariant: ['tabular-nums'] },
646
+ hidden: { opacity: 0 },
647
+ scrambled: { position: 'absolute', top: 0, left: -24, right: -24, textAlign: 'center', opacity: 0.45 },
648
+ ghost: { color: 'transparent' },
649
+ overlay: { position: 'absolute', top: 0 },
650
+ });