@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,532 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ Pressable,
7
+ StyleSheet,
8
+ View,
9
+ type LayoutChangeEvent,
10
+ type StyleProp,
11
+ type TextStyle,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import Svg, { Circle, Path } from 'react-native-svg';
15
+ import * as Haptics from 'expo-haptics';
16
+
17
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
18
+
19
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
20
+ const CHECK = 'M6 12.5l4 4L18.5 8';
21
+ const CHECK_LEN = 18;
22
+ const CROSS = 'M7.5 7.5l9 9M16.5 7.5l-9 9';
23
+ const CROSS_LEN = 26;
24
+
25
+ export type StatefulButtonState = 'idle' | 'loading' | 'success' | 'error';
26
+ export type StatefulButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
27
+ export type StatefulButtonSize = 'sm' | 'md' | 'lg';
28
+
29
+ export interface StatefulButtonProps {
30
+ /** Label while idle. */
31
+ children: string;
32
+ /** Controlled state. Leave it out and return a Promise from `onPress` to let the button drive itself. */
33
+ state?: StatefulButtonState;
34
+ /** If it returns a Promise and `state` is not set: loading → success / error → idle. */
35
+ onPress?: () => void | Promise<unknown>;
36
+ /** Label while loading (default: the idle label). */
37
+ loadingText?: string;
38
+ /** Label after success (default 'Done'). */
39
+ successText?: string;
40
+ /** Label after an error (default 'Failed'). */
41
+ errorText?: string;
42
+ variant?: StatefulButtonVariant;
43
+ size?: StatefulButtonSize;
44
+ /** Tint for the idle look (default: theme primary / foreground). */
45
+ color?: string;
46
+ /** Uncontrolled only: how long success / error shows before returning to idle, in ms. */
47
+ resetAfter?: number;
48
+ /** Uncontrolled only: minimum spinner time, so fast requests still read as "work happened". */
49
+ minLoading?: number;
50
+ /** Called on every state change (both modes). */
51
+ onStateChange?: (state: StatefulButtonState) => void;
52
+ /** Fully rounded ends. */
53
+ pill?: boolean;
54
+ /** Stretch to the parent's width (no width animation). */
55
+ block?: boolean;
56
+ disabled?: boolean;
57
+ /** Tap on press, success and error buzzes (expo-haptics). Default true. */
58
+ haptics?: boolean;
59
+ accessibilityLabel?: string;
60
+ style?: StyleProp<ViewStyle>;
61
+ textStyle?: StyleProp<TextStyle>;
62
+ }
63
+
64
+ const SIZES: Record<StatefulButtonSize, { h: number; px: number; font: number; icon: number; gap: number }> = {
65
+ sm: { h: 36, px: 14, font: 14, icon: 16, gap: 6 },
66
+ md: { h: 46, px: 18, font: 15.5, icon: 18, gap: 8 },
67
+ lg: { h: 54, px: 22, font: 17, icon: 20, gap: 9 },
68
+ };
69
+ const STAGGER = 22;
70
+ const END = 1e6;
71
+ // Connected scripts (Arabic, Indic, Thai…) roll as one piece instead of letter by letter.
72
+ const CONNECTED = /[֐-ࣿऀ-෿฀-໿ༀ-࿿က-႟ក-៿יִ-﷿ﹰ-]/;
73
+
74
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
75
+ const isThenable = (v: unknown): v is PromiseLike<unknown> =>
76
+ typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
77
+ const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
78
+ const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
79
+ /** A firm spring (~4% overshoot), sampled so it runs on the native driver. */
80
+ const settle = (t: number) => 1 - Math.exp(-7 * t) * Math.cos(7 * t);
81
+
82
+ /** Maps a ms clock onto `from → to` over [start, start + dur] through `ease` (pre-sampled for the native driver). */
83
+ function track(clock: Animated.Value, start: number, dur: number, from: number, to: number, ease: (t: number) => number, steps = 14) {
84
+ const inputRange: number[] = [];
85
+ const outputRange: number[] = [];
86
+ for (let k = 0; k <= steps; k++) {
87
+ const t = k / steps;
88
+ inputRange.push(start + dur * t);
89
+ outputRange.push(k === steps ? to : from + (to - from) * ease(t));
90
+ }
91
+ return clock.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
92
+ }
93
+
94
+ function useReduceMotion() {
95
+ const [reduce, setReduce] = useState(false);
96
+ useEffect(() => {
97
+ let alive = true;
98
+ AccessibilityInfo.isReduceMotionEnabled()
99
+ .then((r) => alive && setReduce(r))
100
+ .catch(() => {});
101
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
102
+ return () => {
103
+ alive = false;
104
+ sub.remove();
105
+ };
106
+ }, []);
107
+ return reduce;
108
+ }
109
+
110
+ const split = (text: string) => (CONNECTED.test(text) ? [text] : Array.from(text));
111
+
112
+ type Anim = { opacity: Animated.AnimatedInterpolation<number>; translateY: Animated.AnimatedInterpolation<number> | number };
113
+
114
+ interface LettersProps {
115
+ text: string;
116
+ style: StyleProp<TextStyle>;
117
+ lh: number;
118
+ space: number;
119
+ anims?: Anim[];
120
+ onLayout?: (e: LayoutChangeEvent) => void;
121
+ }
122
+
123
+ /** A row of letters, each clipped to one line so it can roll in and out. */
124
+ function Letters({ text, style, lh, space, anims, onLayout }: LettersProps) {
125
+ return (
126
+ <View style={styles.letters} onLayout={onLayout}>
127
+ {split(text).map((ch, i) =>
128
+ ch === ' ' ? (
129
+ <View key={i} style={{ width: space, height: lh }} />
130
+ ) : (
131
+ <View key={i} style={{ height: lh, overflow: 'hidden' }}>
132
+ <Animated.Text
133
+ numberOfLines={1}
134
+ style={[style, anims?.[i] && { opacity: anims[i].opacity, transform: [{ translateY: anims[i].translateY }] }]}
135
+ >
136
+ {ch}
137
+ </Animated.Text>
138
+ </View>
139
+ ),
140
+ )}
141
+ </View>
142
+ );
143
+ }
144
+
145
+ interface Roll {
146
+ text: string;
147
+ color: string;
148
+ prev: { text: string; color: string; width: number } | null;
149
+ id: number;
150
+ }
151
+
152
+ /** Label whose letters roll up and out while the next label rolls in from below (crossfade with reduced motion). */
153
+ function RollLabel({ text, color, style, lh, space, reduce }: { text: string; color: string; style: StyleProp<TextStyle>; lh: number; space: number; reduce: boolean }) {
154
+ const width = useRef(0);
155
+ const [roll, setRoll] = useState<Roll>({ text, color, prev: null, id: 0 });
156
+ if (roll.text !== text) setRoll({ text, color, prev: { text: roll.text, color: roll.color, width: width.current }, id: roll.id + 1 });
157
+ else if (roll.color !== color) setRoll({ ...roll, color });
158
+
159
+ const clockRef = useRef<{ id: number; v: Animated.Value } | null>(null);
160
+ if (!clockRef.current || clockRef.current.id !== roll.id) {
161
+ clockRef.current = { id: roll.id, v: new Animated.Value(roll.id === 0 ? END : 0) };
162
+ }
163
+ const clock = clockRef.current.v;
164
+
165
+ const live = useRef({ next: 0, prev: 0, reduce });
166
+ live.current = { next: split(roll.text).length, prev: roll.prev ? split(roll.prev.text).length : 0, reduce };
167
+ const id = roll.id;
168
+ useEffect(() => {
169
+ if (id === 0) return;
170
+ const { next, prev, reduce: still } = live.current;
171
+ const total = still ? 280 : Math.max(next, prev) * STAGGER + 60 + 520;
172
+ const anim = Animated.timing(clock, { toValue: total, duration: total, easing: Easing.linear, useNativeDriver: true });
173
+ anim.start(({ finished }) => {
174
+ if (!finished) return;
175
+ clock.setValue(END);
176
+ setRoll((r) => (r.id === id ? { ...r, prev: null } : r));
177
+ });
178
+ return () => anim.stop();
179
+ }, [id, clock]);
180
+
181
+ const n = live.current.next;
182
+ const m = live.current.prev;
183
+ const enter = useMemo<Anim[]>(
184
+ () =>
185
+ Array.from({ length: n }, (_, i) =>
186
+ reduce
187
+ ? { opacity: track(clock, 60, 220, 0, 1, easeOut), translateY: 0 }
188
+ : { opacity: track(clock, i * STAGGER + 60, 200, 0, 1, easeOut), translateY: track(clock, i * STAGGER + 60, 520, lh, 0, settle, 18) },
189
+ ),
190
+ [n, clock, lh, reduce],
191
+ );
192
+ const exitStagger = Math.min(STAGGER, 120 / Math.max(m, 1));
193
+ const exit = useMemo<Anim[]>(
194
+ () =>
195
+ Array.from({ length: m }, (_, i) =>
196
+ reduce
197
+ ? { opacity: track(clock, 0, 180, 1, 0, easeOut), translateY: 0 }
198
+ : // The button narrows as soon as the label changes, so the old label clears out fast (≤ 120ms of
199
+ // stagger whatever its length) instead of trailing letters past the shrinking edge.
200
+ { opacity: track(clock, i * exitStagger, 110, 1, 0, easeOut), translateY: track(clock, i * exitStagger, 300, 0, -lh, easeInOut) },
201
+ ),
202
+ [m, clock, lh, reduce, exitStagger],
203
+ );
204
+
205
+ const pw = roll.prev?.width ?? 0;
206
+ return (
207
+ <View>
208
+ <Letters
209
+ text={roll.text}
210
+ style={[style, { color: roll.color }]}
211
+ lh={lh}
212
+ space={space}
213
+ anims={enter}
214
+ onLayout={(e) => (width.current = e.nativeEvent.layout.width)}
215
+ />
216
+ {roll.prev ? (
217
+ <View pointerEvents="none" style={[styles.prev, pw > 0 ? { left: '50%', marginLeft: -pw / 2, width: pw } : { left: 0 }]}>
218
+ <Letters text={roll.prev.text} style={[style, { color: roll.prev.color }]} lh={lh} space={space} anims={exit} />
219
+ </View>
220
+ ) : null}
221
+ </View>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Button that walks through idle → loading → success / error. The label rolls letter by letter to
227
+ * the next text, the width springs to fit it, and a spinner, self-drawing check or shaking cross
228
+ * grows in beside it. Controlled with `state`, or self-driving when `onPress` returns a Promise.
229
+ */
230
+ export function StatefulButton({
231
+ children,
232
+ state,
233
+ onPress,
234
+ loadingText,
235
+ successText = 'Done',
236
+ errorText = 'Failed',
237
+ variant = 'primary',
238
+ size = 'md',
239
+ color,
240
+ resetAfter = 1600,
241
+ minLoading = 500,
242
+ onStateChange,
243
+ pill,
244
+ block,
245
+ disabled,
246
+ haptics = true,
247
+ accessibilityLabel,
248
+ style,
249
+ textStyle,
250
+ }: StatefulButtonProps) {
251
+ const { colors, radius, dark, font } = useKit();
252
+ const reduce = useReduceMotion();
253
+ const s = SIZES[size];
254
+ const fontSize = StyleSheet.flatten(textStyle)?.fontSize ?? s.font;
255
+ const lh = Math.round(fontSize * 1.3);
256
+ const space = fontSize * 0.28;
257
+
258
+ const [inner, setInner] = useState<StatefulButtonState>('idle');
259
+ const current = state ?? inner;
260
+ const busy = useRef(false);
261
+ const mounted = useRef(true);
262
+ useEffect(() => {
263
+ mounted.current = true;
264
+ return () => {
265
+ mounted.current = false;
266
+ };
267
+ }, []);
268
+
269
+ const labels: Record<StatefulButtonState, string> = {
270
+ idle: children,
271
+ loading: loadingText ?? children,
272
+ success: successText,
273
+ error: errorText,
274
+ };
275
+ const label = labels[current];
276
+
277
+ // Looks: idle (also used while loading), success and error. Transparent fills keep their hue so fades stay clean.
278
+ const ok = colors.success;
279
+ const bad = colors.danger;
280
+ const soft = dark ? 0.2 : 0.14;
281
+ const looks: Record<StatefulButtonVariant, [{ bg: string; fg: string; border: string }, { bg: string; fg: string; border: string }, { bg: string; fg: string; border: string }]> = {
282
+ primary: [
283
+ { bg: color ?? colors.primary, fg: color ? readableOn(color) : colors.primaryForeground, border: alpha(color ?? colors.primary, 0) },
284
+ { bg: ok, fg: readableOn(ok), border: alpha(ok, 0) },
285
+ { bg: bad, fg: colors.dangerForeground, border: alpha(bad, 0) },
286
+ ],
287
+ secondary: [
288
+ { bg: color ? alpha(color, soft) : colors.secondary, fg: color ?? colors.secondaryForeground, border: alpha(colors.secondary, 0) },
289
+ { bg: alpha(ok, soft), fg: ok, border: alpha(ok, 0) },
290
+ { bg: alpha(bad, soft), fg: bad, border: alpha(bad, 0) },
291
+ ],
292
+ outline: [
293
+ { bg: alpha(color ?? colors.foreground, 0), fg: color ?? colors.foreground, border: color ? alpha(color, 0.5) : colors.border },
294
+ { bg: alpha(ok, 0), fg: ok, border: alpha(ok, 0.6) },
295
+ { bg: alpha(bad, 0), fg: bad, border: alpha(bad, 0.6) },
296
+ ],
297
+ ghost: [
298
+ { bg: alpha(color ?? colors.foreground, 0), fg: color ?? colors.foreground, border: alpha(color ?? colors.foreground, 0) },
299
+ { bg: alpha(ok, 0), fg: ok, border: alpha(ok, 0) },
300
+ { bg: alpha(bad, 0), fg: bad, border: alpha(bad, 0) },
301
+ ],
302
+ };
303
+ const look = looks[variant];
304
+ const toneIndex = current === 'success' ? 1 : current === 'error' ? 2 : 0;
305
+ const fg = look[toneIndex].fg;
306
+ const bw = variant === 'outline' ? StyleSheet.hairlineWidth * 2 : 0;
307
+
308
+ // JS driver (layout + colour + SVG) — on the surface, the icon slot and the stroke.
309
+ const tone = useRef(new Animated.Value(toneIndex)).current;
310
+ const slot = useRef(new Animated.Value(current === 'idle' ? 0 : s.icon + s.gap)).current;
311
+ const draw = useRef(new Animated.Value(1)).current;
312
+ // Native driver — transforms and opacity on their own wrappers.
313
+ const press = useRef(new Animated.Value(1)).current;
314
+ const shake = useRef(new Animated.Value(0)).current;
315
+ const pop = useRef(new Animated.Value(current === 'idle' ? 0 : 1)).current;
316
+ const spin = useRef(new Animated.Value(0)).current;
317
+ const spinLoop = useRef<Animated.CompositeAnimation | null>(null);
318
+
319
+ // The icon keeps showing the last busy/result state while it shrinks away on the way back to idle.
320
+ const lastIcon = useRef<StatefulButtonState>(current === 'idle' ? 'loading' : current);
321
+ if (current !== 'idle') lastIcon.current = current;
322
+ const icon = lastIcon.current;
323
+
324
+ // Width: measured from hidden copies of every label, sprung on the JS driver.
325
+ const [widths, setWidths] = useState<Record<string, number>>({});
326
+ const measured = widths[label];
327
+ const target = measured === undefined ? undefined : Math.max(s.h, Math.ceil(measured) + s.px * 2 + bw * 2 + (current === 'idle' ? 0 : s.icon + s.gap));
328
+ const widthRef = useRef<Animated.Value | null>(null);
329
+ if (!widthRef.current && target !== undefined) widthRef.current = new Animated.Value(target);
330
+ const width = widthRef.current;
331
+
332
+ const live = useRef({ reduce, haptics, onStateChange, label, slotTo: 0 });
333
+ live.current = { reduce, haptics, onStateChange, label, slotTo: current === 'idle' ? 0 : s.icon + s.gap };
334
+
335
+ useEffect(() => {
336
+ if (!width || target === undefined) return;
337
+ if (live.current.reduce) width.setValue(target);
338
+ else Animated.spring(width, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();
339
+ }, [width, target]);
340
+
341
+ // Everything that happens on a state change.
342
+ const seen = useRef(current);
343
+ useEffect(() => {
344
+ if (seen.current === current) return;
345
+ seen.current = current;
346
+ const { reduce: still, haptics: buzz, onStateChange: notify, slotTo } = live.current;
347
+ notify?.(current);
348
+
349
+ if (still) slot.setValue(slotTo);
350
+ else Animated.spring(slot, { toValue: slotTo, useNativeDriver: false, ...motion.snappy }).start();
351
+ Animated.timing(tone, { toValue: current === 'success' ? 1 : current === 'error' ? 2 : 0, duration: 220, useNativeDriver: false }).start();
352
+
353
+ if (current === 'idle') {
354
+ Animated.timing(pop, { toValue: 0, duration: 140, useNativeDriver: true }).start();
355
+ } else {
356
+ pop.setValue(still ? 1 : 0.2);
357
+ if (!still) Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
358
+ }
359
+
360
+ spinLoop.current?.stop();
361
+ spinLoop.current = null;
362
+ if (current === 'loading') {
363
+ spin.setValue(0);
364
+ spinLoop.current = Animated.loop(Animated.timing(spin, { toValue: 1, duration: 800, easing: Easing.linear, useNativeDriver: true }));
365
+ spinLoop.current.start();
366
+ }
367
+
368
+ if (current === 'success' || current === 'error') {
369
+ draw.setValue(still ? 1 : 0);
370
+ if (!still) Animated.timing(draw, { toValue: 1, duration: 380, delay: 90, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
371
+ if (buzz)
372
+ Haptics.notificationAsync(
373
+ current === 'success' ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error
374
+ ).catch(() => {});
375
+ }
376
+ if (current === 'error' && !still) {
377
+ shake.setValue(0);
378
+ Animated.sequence([10, -8, 6, -4, 2, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 50, useNativeDriver: true }))).start();
379
+ }
380
+ }, [current, slot, tone, pop, spin, draw, shake]);
381
+
382
+ useEffect(() => () => spinLoop.current?.stop(), []);
383
+
384
+ // Screen readers hear every new label (Saving…, Saved, Following…), not just the first.
385
+ const heard = useRef(label);
386
+ useEffect(() => {
387
+ if (heard.current === label) return;
388
+ heard.current = label;
389
+ AccessibilityInfo.announceForAccessibility(label);
390
+ }, [label]);
391
+
392
+ const isDisabled = !!disabled || current === 'loading';
393
+
394
+ const run = () => {
395
+ if (isDisabled || busy.current) return;
396
+ if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
397
+ const result: unknown = onPress?.();
398
+ if (state !== undefined || !isThenable(result)) return;
399
+ busy.current = true;
400
+ setInner('loading');
401
+ const started = Date.now();
402
+ Promise.resolve(result)
403
+ .then(
404
+ () => false,
405
+ () => true,
406
+ )
407
+ .then(async (failed) => {
408
+ const remaining = minLoading - (Date.now() - started);
409
+ if (remaining > 0) await wait(remaining);
410
+ if (!mounted.current) return;
411
+ setInner(failed ? 'error' : 'success');
412
+ await wait(resetAfter);
413
+ if (!mounted.current) return;
414
+ setInner('idle');
415
+ busy.current = false;
416
+ });
417
+ };
418
+
419
+ const onMeasure = (text: string) => (e: LayoutChangeEvent) => {
420
+ const w = e.nativeEvent.layout.width;
421
+ setWidths((prev) => (prev[text] === w ? prev : { ...prev, [text]: w }));
422
+ };
423
+
424
+ const textBase: StyleProp<TextStyle> = [styles.label, { fontSize: s.font, fontFamily: font.family }, textStyle, { lineHeight: lh }];
425
+ // Press feedback: a light wash on dark fills, a faint shade on light or transparent ones.
426
+ const wash = variant === 'primary' && readableOn(look[0].bg) === '#ffffff' ? alpha('#ffffff', 0.14) : alpha(colors.foreground, 0.07);
427
+ const corner = pill ? radius.capsule(s.h) : radius.md;
428
+ const hit = Math.max(0, (44 - s.h) / 2);
429
+
430
+ return (
431
+ <Animated.View style={[{ transform: [{ translateX: shake }, { scale: press }] }, block ? styles.block : styles.inline, style]}>
432
+ <Pressable
433
+ accessibilityRole="button"
434
+ accessibilityLabel={accessibilityLabel ?? label}
435
+ accessibilityState={{ disabled: isDisabled, busy: current === 'loading' }}
436
+ disabled={isDisabled}
437
+ hitSlop={hit ? { top: hit, bottom: hit } : undefined}
438
+ onPress={run}
439
+ onPressIn={() => Animated.spring(press, { toValue: 0.96, useNativeDriver: true, ...motion.bouncy }).start()}
440
+ onPressOut={() => Animated.spring(press, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start()}
441
+ >
442
+ {({ pressed }) => (
443
+ <Animated.View
444
+ style={[
445
+ styles.surface,
446
+ {
447
+ height: s.h,
448
+ minWidth: s.h,
449
+ paddingHorizontal: s.px,
450
+ borderRadius: corner,
451
+ borderWidth: bw,
452
+ borderColor: tone.interpolate({ inputRange: [0, 1, 2], outputRange: [look[0].border, look[1].border, look[2].border] }),
453
+ backgroundColor: tone.interpolate({ inputRange: [0, 1, 2], outputRange: [look[0].bg, look[1].bg, look[2].bg] }),
454
+ opacity: disabled ? 0.5 : 1,
455
+ },
456
+ !block && width ? { width } : null,
457
+ ]}
458
+ >
459
+ {pressed ? <View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: wash }]} /> : null}
460
+ <Animated.View style={[styles.slot, { width: slot, height: s.icon }]}>
461
+ <Animated.View
462
+ style={[
463
+ styles.icon,
464
+ {
465
+ width: s.icon,
466
+ height: s.icon,
467
+ opacity: pop.interpolate({ inputRange: [0, 0.6], outputRange: [0, 1], extrapolate: 'clamp' }),
468
+ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }],
469
+ },
470
+ ]}
471
+ >
472
+ {icon === 'loading' ? (
473
+ <Animated.View style={{ transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }] }}>
474
+ <Svg width={s.icon} height={s.icon} viewBox="0 0 24 24">
475
+ <Circle cx={12} cy={12} r={9} stroke={alpha(fg, 0.25)} strokeWidth={2.75} fill="none" />
476
+ <Circle cx={12} cy={12} r={9} stroke={fg} strokeWidth={2.75} strokeLinecap="round" strokeDasharray="15 60" fill="none" />
477
+ </Svg>
478
+ </Animated.View>
479
+ ) : (
480
+ <Svg width={s.icon} height={s.icon} viewBox="0 0 24 24">
481
+ <AnimatedPath
482
+ d={icon === 'error' ? CROSS : CHECK}
483
+ stroke={fg}
484
+ strokeWidth={3}
485
+ strokeLinecap="round"
486
+ strokeLinejoin="round"
487
+ fill="none"
488
+ strokeDasharray={icon === 'error' ? CROSS_LEN : CHECK_LEN}
489
+ strokeDashoffset={
490
+ draw.interpolate({
491
+ inputRange: [0, 1],
492
+ outputRange: [icon === 'error' ? CROSS_LEN : CHECK_LEN, 0],
493
+ extrapolate: 'clamp',
494
+ }) as unknown as number
495
+ }
496
+ />
497
+ </Svg>
498
+ )}
499
+ </Animated.View>
500
+ </Animated.View>
501
+ <RollLabel text={label} color={fg} style={textBase} lh={lh} space={space} reduce={reduce} />
502
+ </Animated.View>
503
+ )}
504
+ </Pressable>
505
+
506
+ {/* Hidden copies of each label, laid out exactly like the visible one, to know every width up front. */}
507
+ <View pointerEvents="none" style={styles.measure} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
508
+ <View style={styles.measureRow}>
509
+ {Array.from(new Set(Object.values(labels))).map((text) => (
510
+ <View key={text} style={styles.measureItem}>
511
+ <Letters text={text} style={textBase} lh={lh} space={space} onLayout={onMeasure(text)} />
512
+ </View>
513
+ ))}
514
+ </View>
515
+ </View>
516
+ </Animated.View>
517
+ );
518
+ }
519
+
520
+ const styles = StyleSheet.create({
521
+ inline: { alignSelf: 'flex-start' },
522
+ block: { alignSelf: 'stretch' },
523
+ surface: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
524
+ slot: { justifyContent: 'center' },
525
+ icon: { position: 'absolute', left: 0 },
526
+ letters: { flexDirection: 'row', alignItems: 'flex-start' },
527
+ label: { fontWeight: '400', letterSpacing: -0.1, includeFontPadding: false },
528
+ prev: { position: 'absolute', top: 0 },
529
+ measure: { position: 'absolute', top: 0, left: 0, width: 0, height: 0, overflow: 'hidden', opacity: 0 },
530
+ measureRow: { width: 4000, flexDirection: 'column', alignItems: 'flex-start' },
531
+ measureItem: { flexDirection: 'row' },
532
+ });
@@ -181,14 +181,17 @@ export function useKit(): Theme {
181
181
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
182
  }
183
183
 
184
- /** Shared motion presets so the whole kit moves the same way. */
184
+ /**
185
+ * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
+ * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
187
+ */
185
188
  export const motion = {
186
189
  /** Toggles, selection indicators. */
187
- snappy: { damping: 18, stiffness: 260, mass: 0.8 },
188
- /** Press feedback, pops. */
189
- bouncy: { damping: 11, stiffness: 220, mass: 0.7 },
190
+ snappy: { damping: 30, stiffness: 260, mass: 0.8 },
191
+ /** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
192
+ bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
190
193
  /** Sheets, layout changes. */
191
- gentle: { damping: 22, stiffness: 160, mass: 1 },
194
+ gentle: { damping: 27, stiffness: 160, mass: 1 },
192
195
  fast: 160,
193
196
  normal: 240,
194
197
  };
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "stateful-button",
3
+ "type": "component",
4
+ "title": "Stateful Button",
5
+ "description": "A button that walks through idle, loading, success and error: the label rolls letter by letter to the next text while the width springs to fit it. A spinner, a self-drawing check or a shaking cross grows in beside the label, and it drives itself when onPress returns a Promise.",
6
+ "tier": "free",
7
+ "dependencies": [
8
+ "react-native-svg",
9
+ "expo-haptics"
10
+ ],
11
+ "registryDependencies": [
12
+ "kit"
13
+ ],
14
+ "sharedFiles": [
15
+ "lib/kit.tsx"
16
+ ],
17
+ "filesDir": "stateful-button/files"
18
+ }
@@ -118,7 +118,8 @@ export function Stepper({
118
118
  buttonStyle,
119
119
  valueStyle,
120
120
  }: StepperProps) {
121
- const { colors, radius } = useKit();
121
+ const { colors, radius, font } = useKit();
122
+ const ff = font.family ? { fontFamily: font.family } : null;
122
123
  const variant: StepperVariant = variantProp in LEGACY ? LEGACY[variantProp as StepperLegacyVariant] : (variantProp as StepperVariant);
123
124
  const vertical = variant !== 'cart' && (orientation ? orientation === 'vertical' : variantProp === 'vertical');
124
125
  const tint = accent ?? colors.accent;
@@ -199,6 +200,7 @@ export function Stepper({
199
200
  fontSize,
200
201
  lineHeight: Math.round(fontSize * 1.3),
201
202
  color: numColor,
203
+ ...ff,
202
204
  ...flatValue,
203
205
  };
204
206
 
@@ -312,8 +314,8 @@ export function Stepper({
312
314
  return (
313
315
  <View style={[styles.labelled, style]}>
314
316
  <View style={styles.texts}>
315
- {title ? <Text style={[styles.label, { color: colors.foreground }]}>{title}</Text> : null}
316
- {description ? <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text> : null}
317
+ {title ? <Text style={[styles.label, ff, { color: colors.foreground }]}>{title}</Text> : null}
318
+ {description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
317
319
  </View>
318
320
  {control}
319
321
  </View>
@@ -361,6 +363,8 @@ function CartControl({
361
363
  onAdd: () => void;
362
364
  onAddEnd: () => void;
363
365
  } & React.ComponentProps<typeof View>) {
366
+ const { font } = useKit();
367
+ const ff = font.family ? { fontFamily: font.family } : null;
364
368
  // Natural widths, measured off-screen so the spring always lands on the real size.
365
369
  const [textW, setTextW] = useState(0);
366
370
  const [addW, setAddW] = useState(0);
@@ -438,7 +442,7 @@ function CartControl({
438
442
  style={({ pressed }) => [styles.add, { height: s.btn, paddingHorizontal: Math.round(s.btn * 0.36) }, pressed && { backgroundColor: alpha(fg, 0.16) }]}
439
443
  >
440
444
  <Glyph d={ICON.plus} size={s.icon} color={fg} />
441
- <Text style={[styles.addText, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
445
+ <Text style={[styles.addText, ff, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
442
446
  </Pressable>
443
447
  </Animated.View>
444
448
  </View>
@@ -181,14 +181,17 @@ export function useKit(): Theme {
181
181
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
182
  }
183
183
 
184
- /** Shared motion presets so the whole kit moves the same way. */
184
+ /**
185
+ * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
+ * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
187
+ */
185
188
  export const motion = {
186
189
  /** Toggles, selection indicators. */
187
- snappy: { damping: 18, stiffness: 260, mass: 0.8 },
188
- /** Press feedback, pops. */
189
- bouncy: { damping: 11, stiffness: 220, mass: 0.7 },
190
+ snappy: { damping: 30, stiffness: 260, mass: 0.8 },
191
+ /** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
192
+ bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
190
193
  /** Sheets, layout changes. */
191
- gentle: { damping: 22, stiffness: 160, mass: 1 },
194
+ gentle: { damping: 27, stiffness: 160, mass: 1 },
192
195
  fast: 160,
193
196
  normal: 240,
194
197
  };
@@ -63,7 +63,8 @@ export function Switch({
63
63
  accessibilityLabel,
64
64
  style,
65
65
  }: SwitchProps) {
66
- const { colors, dark } = useKit();
66
+ const { colors, dark, font } = useKit();
67
+ const ff = font.family ? { fontFamily: font.family } : null;
67
68
  const gid = 'sw' + useId().replace(/[^a-zA-Z0-9]/g, '');
68
69
  const tint = accent ?? colors.accent;
69
70
  const [inner, setInner] = useState(defaultValue);
@@ -315,8 +316,8 @@ export function Switch({
315
316
  return (
316
317
  <Pressable {...a11y} style={[styles.row, { opacity: disabled ? 0.45 : 1 }, style]}>
317
318
  <View style={styles.text}>
318
- <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
319
- {description ? <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text> : null}
319
+ <Text style={[styles.label, ff, { color: colors.foreground }]}>{label}</Text>
320
+ {description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
320
321
  </View>
321
322
  {control}
322
323
  </Pressable>