@expokit/cli 0.3.0 → 0.4.1

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 (188) hide show
  1. package/README.md +8 -4
  2. package/bin/expo-kit.js +137 -24
  3. package/bin/lib.mjs +110 -1
  4. package/bin/mcp.mjs +40 -18
  5. package/package.json +2 -2
  6. package/registry/_docs/accordion.json +1 -1
  7. package/registry/_docs/assistant-app.json +47 -0
  8. package/registry/_docs/avatar.json +6 -6
  9. package/registry/_docs/badge.json +4 -4
  10. package/registry/_docs/bottom-nav.json +6 -6
  11. package/registry/_docs/bottom-sheet.json +3 -3
  12. package/registry/_docs/bullet.json +6 -0
  13. package/registry/_docs/button-3d.json +3 -3
  14. package/registry/_docs/button.json +2 -2
  15. package/registry/_docs/calendar.json +5 -5
  16. package/registry/_docs/card.json +1 -1
  17. package/registry/_docs/checkout.json +7 -1
  18. package/registry/_docs/chip-group.json +1 -1
  19. package/registry/_docs/confetti.json +3 -3
  20. package/registry/_docs/date-picker.json +4 -4
  21. package/registry/_docs/donut-shop.json +88 -0
  22. package/registry/_docs/drawer.json +4 -4
  23. package/registry/_docs/fintech-app.json +49 -0
  24. package/registry/_docs/flip-card.json +4 -4
  25. package/registry/_docs/floating-input.json +5 -5
  26. package/registry/_docs/food-app.json +47 -0
  27. package/registry/_docs/glass.json +1 -1
  28. package/registry/_docs/grain.json +80 -0
  29. package/registry/_docs/isometric-bar.json +6 -0
  30. package/registry/_docs/liquidglass-tab.json +1 -1
  31. package/registry/_docs/morphing-button-group.json +96 -0
  32. package/registry/_docs/orb-reveal.json +132 -0
  33. package/registry/_docs/otp-input.json +4 -4
  34. package/registry/_docs/password-input.json +3 -3
  35. package/registry/_docs/payment-card.json +3 -3
  36. package/registry/_docs/pixel-bar.json +6 -0
  37. package/registry/_docs/polar-area.json +6 -0
  38. package/registry/_docs/progress-steps.json +4 -4
  39. package/registry/_docs/progress.json +2 -2
  40. package/registry/_docs/qr-code-button.json +122 -0
  41. package/registry/_docs/radio-group.json +1 -1
  42. package/registry/_docs/search-bar.json +4 -4
  43. package/registry/_docs/segmented-control.json +1 -1
  44. package/registry/_docs/select.json +7 -7
  45. package/registry/_docs/settings.json +6 -0
  46. package/registry/_docs/skeleton.json +3 -3
  47. package/registry/_docs/slider.json +6 -6
  48. package/registry/_docs/spinner.json +3 -3
  49. package/registry/_docs/stateful-button.json +1 -1
  50. package/registry/_docs/stepper.json +7 -7
  51. package/registry/_docs/tabs.json +2 -2
  52. package/registry/_docs/text-animation.json +4 -4
  53. package/registry/_docs/textarea.json +3 -3
  54. package/registry/_docs/ticket-shape.json +1 -1
  55. package/registry/_docs/tilt-card.json +4 -4
  56. package/registry/_docs/time-picker.json +6 -6
  57. package/registry/_docs/timeline-chart.json +6 -0
  58. package/registry/_docs/timeline.json +6 -6
  59. package/registry/_docs/toast.json +8 -8
  60. package/registry/_docs/treemap.json +6 -0
  61. package/registry/_docs/waterfall.json +6 -0
  62. package/registry/accordion/files/src/components/ui/accordion.tsx +36 -7
  63. package/registry/accordion/files/src/lib/kit.tsx +8 -5
  64. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +12 -8
  65. package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
  66. package/registry/alert/files/src/components/ui/alert.tsx +11 -5
  67. package/registry/alert/files/src/components/ui/button.tsx +35 -6
  68. package/registry/alert/files/src/lib/kit.tsx +8 -5
  69. package/registry/assistant-app.json +49 -0
  70. package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
  71. package/registry/avatar/files/src/lib/kit.tsx +8 -5
  72. package/registry/badge/files/src/components/ui/badge.tsx +23 -9
  73. package/registry/badge/files/src/lib/kit.tsx +8 -5
  74. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +21 -8
  75. package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
  76. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
  77. package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
  78. package/registry/button/files/src/components/ui/button.tsx +35 -6
  79. package/registry/button/files/src/lib/kit.tsx +8 -5
  80. package/registry/button-3d/files/src/components/ui/button-3d.tsx +35 -7
  81. package/registry/button-3d/files/src/lib/kit.tsx +8 -5
  82. package/registry/calendar/files/src/components/ui/calendar.tsx +68 -19
  83. package/registry/calendar/files/src/lib/kit.tsx +8 -5
  84. package/registry/card/files/src/components/ui/card.tsx +32 -10
  85. package/registry/card/files/src/lib/kit.tsx +8 -5
  86. package/registry/checkbox/files/src/components/ui/checkbox.tsx +31 -8
  87. package/registry/checkbox/files/src/lib/kit.tsx +8 -5
  88. package/registry/chip-group/files/src/components/ui/chip-group.tsx +39 -7
  89. package/registry/chip-group/files/src/lib/kit.tsx +8 -5
  90. package/registry/confetti/files/src/lib/kit.tsx +8 -5
  91. package/registry/coupon/files/src/components/ui/coupon.tsx +55 -28
  92. package/registry/coupon/files/src/components/ui/flip-card.tsx +23 -4
  93. package/registry/coupon/files/src/components/ui/tilt-card.tsx +59 -18
  94. package/registry/coupon/files/src/lib/kit.tsx +8 -5
  95. package/registry/date-picker/files/src/components/ui/calendar.tsx +68 -19
  96. package/registry/date-picker/files/src/components/ui/date-picker.tsx +98 -44
  97. package/registry/date-picker/files/src/lib/kit.tsx +8 -5
  98. package/registry/dialog/files/src/components/ui/dialog.tsx +19 -9
  99. package/registry/dialog/files/src/lib/kit.tsx +8 -5
  100. package/registry/donut-shop.json +18 -0
  101. package/registry/drawer/files/src/components/ui/drawer.tsx +31 -21
  102. package/registry/drawer/files/src/lib/kit.tsx +8 -5
  103. package/registry/fintech-app.json +57 -0
  104. package/registry/flip-card/files/src/components/ui/flip-card.tsx +23 -4
  105. package/registry/flip-card/files/src/lib/kit.tsx +8 -5
  106. package/registry/floating-input/files/src/components/ui/floating-input.tsx +91 -32
  107. package/registry/floating-input/files/src/lib/kit.tsx +8 -5
  108. package/registry/food-app.json +55 -0
  109. package/registry/grain/files/src/components/ui/grain.tsx +113 -0
  110. package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
  111. package/registry/grain.json +17 -0
  112. package/registry/kit/files/src/lib/kit.tsx +8 -5
  113. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -2
  114. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
  115. package/registry/list-item/files/src/components/ui/list-item.tsx +28 -17
  116. package/registry/list-item/files/src/lib/kit.tsx +8 -5
  117. package/registry/morphing-button-group.json +18 -0
  118. package/registry/orb-reveal.json +20 -0
  119. package/registry/otp-input/files/src/components/ui/otp-input.tsx +57 -24
  120. package/registry/otp-input/files/src/lib/kit.tsx +8 -5
  121. package/registry/password-input/files/src/components/ui/floating-input.tsx +91 -32
  122. package/registry/password-input/files/src/components/ui/password-input.tsx +55 -21
  123. package/registry/password-input/files/src/lib/kit.tsx +8 -5
  124. package/registry/payment-card/files/src/components/ui/flip-card.tsx +23 -4
  125. package/registry/payment-card/files/src/components/ui/payment-card.tsx +35 -18
  126. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +59 -18
  127. package/registry/payment-card/files/src/lib/kit.tsx +8 -5
  128. package/registry/progress/files/src/components/ui/progress.tsx +16 -6
  129. package/registry/progress/files/src/lib/kit.tsx +8 -5
  130. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +22 -11
  131. package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
  132. package/registry/qr-code-button.json +18 -0
  133. package/registry/radio-group/files/src/components/ui/radio-group.tsx +48 -17
  134. package/registry/radio-group/files/src/lib/kit.tsx +8 -5
  135. package/registry/search-bar/files/src/components/ui/search-bar.tsx +81 -26
  136. package/registry/search-bar/files/src/lib/kit.tsx +8 -5
  137. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +40 -10
  138. package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
  139. package/registry/select/files/src/components/ui/select.tsx +161 -86
  140. package/registry/select/files/src/lib/kit.tsx +8 -5
  141. package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
  142. package/registry/skeleton/files/src/lib/kit.tsx +8 -5
  143. package/registry/slider/files/src/components/ui/slider.tsx +56 -27
  144. package/registry/slider/files/src/lib/kit.tsx +8 -5
  145. package/registry/spinner/files/src/components/ui/spinner.tsx +17 -12
  146. package/registry/spinner/files/src/lib/kit.tsx +8 -5
  147. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  148. package/registry/stateful-button/files/src/lib/kit.tsx +8 -5
  149. package/registry/stepper/files/src/components/ui/stepper.tsx +83 -29
  150. package/registry/stepper/files/src/lib/kit.tsx +8 -5
  151. package/registry/switch/files/src/components/ui/switch.tsx +60 -22
  152. package/registry/switch/files/src/lib/kit.tsx +8 -5
  153. package/registry/tabs/files/src/components/ui/tabs.tsx +15 -10
  154. package/registry/tabs/files/src/lib/kit.tsx +8 -5
  155. package/registry/text-animation/files/src/components/ui/text-animation.tsx +11 -6
  156. package/registry/text-animation/files/src/lib/kit.tsx +8 -5
  157. package/registry/textarea/files/src/components/ui/textarea.tsx +115 -45
  158. package/registry/textarea/files/src/lib/kit.tsx +8 -5
  159. package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
  160. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +59 -18
  161. package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
  162. package/registry/time-picker/files/src/components/ui/time-picker.tsx +156 -75
  163. package/registry/time-picker/files/src/lib/kit.tsx +8 -5
  164. package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
  165. package/registry/timeline/files/src/components/ui/timeline.tsx +19 -12
  166. package/registry/timeline/files/src/lib/kit.tsx +8 -5
  167. package/registry/toast/files/src/components/ui/button.tsx +35 -6
  168. package/registry/toast/files/src/components/ui/toast.tsx +25 -14
  169. package/registry/toast/files/src/lib/kit.tsx +8 -5
  170. package/schema/components.schema.json +1 -1
  171. package/registry/_docs/color-swatch.json +0 -82
  172. package/registry/_docs/kpi-card.json +0 -14
  173. package/registry/_docs/like-button.json +0 -92
  174. package/registry/_docs/morph-button.json +0 -128
  175. package/registry/_docs/pagination-dots.json +0 -108
  176. package/registry/_docs/shimmer-button.json +0 -98
  177. package/registry/_docs/tag-input.json +0 -148
  178. package/registry/color-swatch.json +0 -17
  179. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
  180. package/registry/kpi-card.json +0 -14
  181. package/registry/like-button.json +0 -18
  182. package/registry/morph-button.json +0 -18
  183. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
  184. package/registry/pagination-dots/files/src/lib/kit.tsx +0 -219
  185. package/registry/pagination-dots.json +0 -15
  186. package/registry/shimmer-button.json +0 -17
  187. package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
  188. package/registry/tag-input.json +0 -17
@@ -1,6 +1,7 @@
1
1
  import * as Haptics from 'expo-haptics';
2
- import React, { useId, useRef } from 'react';
2
+ import React, { useEffect, useId, useRef, useState } from 'react';
3
3
  import {
4
+ AccessibilityInfo,
4
5
  ActivityIndicator,
5
6
  Animated,
6
7
  Pressable,
@@ -55,6 +56,10 @@ export interface ButtonProps extends Omit<PressableProps, 'style' | 'children'>
55
56
  textStyle?: StyleProp<TextStyle>;
56
57
  }
57
58
 
59
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
60
+ // content text scales all the way.
61
+ const FONT_CAP = 1.35;
62
+
58
63
  const SIZES: Record<ButtonSize, { h: number; px: number; font: number; gap: number }> = {
59
64
  sm: { h: 36, px: 14, font: 14, gap: 6 },
60
65
  md: { h: 46, px: 18, font: 15.5, gap: 8 },
@@ -116,8 +121,10 @@ export function Button({
116
121
  onPressOut,
117
122
  ...props
118
123
  }: ButtonProps) {
119
- const { colors, radius, dark } = useKit();
124
+ const { colors, radius, dark, font } = useKit();
125
+ const ff = font.family ? { fontFamily: font.family } : null;
120
126
  const gid = 'btn' + useId().replace(/[^a-zA-Z0-9]/g, '');
127
+ const reduce = useReduceMotion();
121
128
  const scale = useRef(new Animated.Value(1)).current;
122
129
  const isDisabled = disabled || loading;
123
130
  const tint = color ?? colors.accent;
@@ -144,12 +151,17 @@ export function Button({
144
151
  const solid = bg !== 'transparent';
145
152
  const quiet = variant === 'ghost' || variant === 'outline' || variant === 'link';
146
153
 
147
- const spring = (to: number) => Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
154
+ // Reduce Motion: no squish; the pressed wash / opacity still marks the press.
155
+ const spring = (to: number) => {
156
+ if (reduce) return scale.setValue(1);
157
+ Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
158
+ };
148
159
 
149
160
  const label = isTextNode(children) ? (
150
161
  <Text
151
162
  numberOfLines={1}
152
- style={[styles.label, { color: fg, fontSize: s.font }, variant === 'link' && styles.link, textStyle]}
163
+ maxFontSizeMultiplier={FONT_CAP}
164
+ style={[styles.label, ff, { color: fg, fontSize: s.font }, variant === 'link' && styles.link, textStyle]}
153
165
  >
154
166
  {children}
155
167
  </Text>
@@ -184,7 +196,8 @@ export function Button({
184
196
  style={({ pressed }) => [
185
197
  styles.base,
186
198
  {
187
- height: s.h,
199
+ minHeight: s.h,
200
+ height: size === 'icon' ? s.h : undefined,
188
201
  width: size === 'icon' ? s.h : undefined,
189
202
  paddingHorizontal: variant === 'link' ? 0 : s.px,
190
203
  gap: s.gap,
@@ -235,7 +248,7 @@ export function Button({
235
248
  <View style={styles.row}>
236
249
  <ActivityIndicator size="small" color={fg} />
237
250
  {size !== 'icon' && isTextNode(children) ? (
238
- <Text style={[styles.label, { color: fg, fontSize: s.font, opacity: 0.85 }]}>{children}</Text>
251
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.label, ff, { color: fg, fontSize: s.font, opacity: 0.85 }]}>{children}</Text>
239
252
  ) : null}
240
253
  </View>
241
254
  ) : (
@@ -252,6 +265,22 @@ export function Button({
252
265
  );
253
266
  }
254
267
 
268
+ function useReduceMotion() {
269
+ const [reduce, setReduce] = useState(false);
270
+ useEffect(() => {
271
+ let alive = true;
272
+ AccessibilityInfo.isReduceMotionEnabled()
273
+ .then((r) => alive && setReduce(r))
274
+ .catch(() => {});
275
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
276
+ return () => {
277
+ alive = false;
278
+ sub.remove();
279
+ };
280
+ }, []);
281
+ return reduce;
282
+ }
283
+
255
284
  const styles = StyleSheet.create({
256
285
  base: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
257
286
  row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
@@ -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
  };
@@ -1,5 +1,5 @@
1
- import React, { useRef } from 'react';
2
- import { ActivityIndicator, Animated, Platform, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, ActivityIndicator, Animated, Platform, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import * as Haptics from 'expo-haptics';
4
4
  import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
5
5
 
@@ -38,6 +38,10 @@ export interface Button3DProps {
38
38
  style?: StyleProp<ViewStyle>;
39
39
  }
40
40
 
41
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
42
+ // content text scales all the way.
43
+ const FONT_CAP = 1.35;
44
+
41
45
  const SIZES = {
42
46
  sm: { h: 36, px: 14, font: 13, depth: 4, gap: 6 },
43
47
  md: { h: 46, px: 18, font: 15, depth: 5, gap: 8 },
@@ -105,7 +109,9 @@ export function Button3D({
105
109
  accessibilityLabel,
106
110
  style,
107
111
  }: Button3DProps) {
108
- const { dark } = useKit();
112
+ const { dark, font } = useKit();
113
+ const reduce = useReduceMotion();
114
+ const ff = font.family ? { fontFamily: font.family } : null;
109
115
  const s = SIZES[size];
110
116
  const depth = depthProp ?? s.depth;
111
117
  const kind = appearance === 'auto' ? (dark ? 'light' : 'dark') : appearance;
@@ -115,14 +121,17 @@ export function Button3D({
115
121
  const gid = `b3d-${color ? color.replace('#', '') : kind}`;
116
122
 
117
123
  const press = useRef(new Animated.Value(0)).current;
118
- const to = (v: number) => Animated.spring(press, { toValue: v, ...(v ? motion.snappy : motion.bouncy), useNativeDriver: true }).start();
124
+ const to = (v: number) =>
125
+ reduce
126
+ ? Animated.timing(press, { toValue: v, duration: 120, useNativeDriver: true }).start()
127
+ : Animated.spring(press, { toValue: v, ...(v ? motion.snappy : motion.bouncy), useNativeDriver: true }).start();
119
128
 
120
129
  const label = loading ? (
121
130
  <ActivityIndicator color={m.ink} size="small" />
122
131
  ) : isTextNode(children) ? (
123
132
  // No numberOfLines: on a narrow fixed-width key ("esc", "Q") the label may use the padding
124
133
  // instead of being cut to an ellipsis.
125
- <Text style={{ color: m.ink, fontSize: round ? s.font + 4 : s.font, fontWeight: '600', letterSpacing: 0.1, flexShrink: 0, textAlign: 'center' }}>
134
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: m.ink, fontSize: round ? s.font + 4 : s.font, fontWeight: '600', letterSpacing: 0.1, flexShrink: 0, textAlign: 'center' }, ff]}>
126
135
  {children}
127
136
  </Text>
128
137
  ) : (
@@ -184,12 +193,15 @@ export function Button3D({
184
193
  style={[
185
194
  styles.face,
186
195
  {
187
- height: s.h,
196
+ // A circle keeps its fixed size; other keys may grow taller for a large text size.
197
+ minHeight: s.h,
198
+ height: round ? s.h : undefined,
188
199
  paddingHorizontal: round ? 0 : s.px,
189
200
  gap: s.gap,
190
201
  borderRadius: corner,
191
202
  elevation: 4,
192
- transform: [{ translateY: press.interpolate({ inputRange: [0, 1], outputRange: [0, depth] }) }],
203
+ // Reduce Motion: the key doesn't sink; only the contact shadow dims.
204
+ transform: reduce ? [] : [{ translateY: press.interpolate({ inputRange: [0, 1], outputRange: [0, depth] }) }],
193
205
  },
194
206
  ]}
195
207
  >
@@ -205,6 +217,22 @@ export function Button3D({
205
217
  );
206
218
  }
207
219
 
220
+ function useReduceMotion() {
221
+ const [reduce, setReduce] = useState(false);
222
+ useEffect(() => {
223
+ let alive = true;
224
+ AccessibilityInfo.isReduceMotionEnabled()
225
+ .then((r) => alive && setReduce(r))
226
+ .catch(() => {});
227
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
228
+ return () => {
229
+ alive = false;
230
+ sub.remove();
231
+ };
232
+ }, []);
233
+ return reduce;
234
+ }
235
+
208
236
  const styles = StyleSheet.create({
209
237
  fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
210
238
  shadow: { shadowColor: '#000', shadowRadius: 8, shadowOffset: { width: 0, height: 5 }, elevation: 3 },
@@ -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
  };
@@ -1,6 +1,8 @@
1
1
  import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
5
+ Easing,
4
6
  PanResponder,
5
7
  Pressable,
6
8
  StyleSheet,
@@ -98,6 +100,9 @@ type FlatProps = CalendarBaseProps & {
98
100
  onChange?: (value: CalendarValue) => void;
99
101
  };
100
102
 
103
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
104
+ // content text scales all the way.
105
+ const FONT_CAP = 1.35;
101
106
  const CELL_H = 48;
102
107
  const CIRCLE = 38;
103
108
  const CIRCLE_TOP = 2;
@@ -201,9 +206,11 @@ export function Calendar(props: CalendarProps) {
201
206
  accent,
202
207
  style,
203
208
  } = props as unknown as FlatProps;
204
- const { colors, radius } = useKit();
209
+ const { colors, radius, font } = useKit();
210
+ const ff = font.family ? { fontFamily: font.family } : null;
205
211
  const tint = accent ?? colors.accent;
206
212
  const fmt = useFormatters(locale);
213
+ const reduce = useReduceMotion();
207
214
 
208
215
  const [inner, setInner] = useState<CalendarValue>(defaultValue ?? null);
209
216
  const current = value !== undefined ? value : inner;
@@ -251,7 +258,11 @@ export function Calendar(props: CalendarProps) {
251
258
  setLeaving({ month: prev, dir });
252
259
  slide.stopAnimation();
253
260
  slide.setValue(0);
254
- Animated.spring(slide, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start(({ finished }) => {
261
+ // Reduce Motion: a short cross-fade in place (the translate interpolations are flat).
262
+ const anim = reduce
263
+ ? Animated.timing(slide, { toValue: 1, duration: 150, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true })
264
+ : Animated.spring(slide, { toValue: 1, useNativeDriver: true, ...motion.gentle });
265
+ anim.start(({ finished }) => {
255
266
  if (finished) setLeaving(null);
256
267
  });
257
268
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -266,7 +277,8 @@ export function Calendar(props: CalendarProps) {
266
277
  if (next !== 'days' && view === 'days') setPickYear(visible.getFullYear());
267
278
  setView(next);
268
279
  viewAnim.setValue(0);
269
- Animated.spring(viewAnim, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
280
+ if (reduce) Animated.timing(viewAnim, { toValue: 1, duration: 150, useNativeDriver: true }).start();
281
+ else Animated.spring(viewAnim, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
270
282
  };
271
283
 
272
284
  /* selection */
@@ -331,11 +343,13 @@ export function Calendar(props: CalendarProps) {
331
343
  manyKeys: many ? many.map(toDateKey).join('|') : undefined,
332
344
  tint,
333
345
  fullLabel: fmt.full,
346
+ reduce,
334
347
  onDayPress,
335
348
  };
336
349
 
337
350
  const W = width || 320;
338
- const dir = dirRef.current;
351
+ // Reduce Motion: months swap with a fade only, no sideways slide.
352
+ const dir = reduce ? 0 : dirRef.current;
339
353
  const prevDisabled = view === 'days' ? monthIndex(visible) - 1 < minM : view === 'months' ? pickYear * 12 - 1 < minM : false;
340
354
  const nextDisabled = view === 'days' ? monthIndex(visible) + 1 > maxM : view === 'months' ? (pickYear + 1) * 12 > maxM : false;
341
355
  const decadeStart = Math.floor(pickYear / 12) * 12;
@@ -368,6 +382,7 @@ export function Calendar(props: CalendarProps) {
368
382
  numberOfLines={1}
369
383
  style={[
370
384
  styles.title,
385
+ ff,
371
386
  { color: view === 'days' ? colors.foreground : tint },
372
387
  view === 'days'
373
388
  ? {
@@ -379,7 +394,7 @@ export function Calendar(props: CalendarProps) {
379
394
  >
380
395
  {title}
381
396
  </Animated.Text>
382
- {monthPicker ? <Chevron turned={view !== 'days'} color={view === 'days' ? colors.mutedForeground : tint} /> : null}
397
+ {monthPicker ? <Chevron turned={view !== 'days'} color={view === 'days' ? colors.mutedForeground : tint} reduce={reduce} /> : null}
383
398
  </Pressable>
384
399
  <View style={styles.navs}>
385
400
  {showJump ? (
@@ -390,7 +405,9 @@ export function Calendar(props: CalendarProps) {
390
405
  hitSlop={4}
391
406
  style={({ pressed }) => [styles.todayBtn, { borderRadius: radius.capsule(32), backgroundColor: pressed ? colors.secondary : colors.muted }]}
392
407
  >
393
- <Text style={[styles.todayText, { color: tint }]}>Today</Text>
408
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.todayText, ff, { color: tint }]}>
409
+ Today
410
+ </Text>
394
411
  </Pressable>
395
412
  ) : null}
396
413
  <NavButton dir={-1} disabled={prevDisabled} onPress={() => step(-1)} />
@@ -402,12 +419,12 @@ export function Calendar(props: CalendarProps) {
402
419
  <Animated.View
403
420
  style={{
404
421
  opacity: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
405
- transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [1.04, 1] }) }],
422
+ transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 1.04, 1] }) }],
406
423
  }}
407
424
  >
408
425
  <View style={styles.weekRow} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
409
426
  {weekdays.map((w) => (
410
- <Text key={w.long} numberOfLines={1} style={[styles.weekday, { color: colors.mutedForeground }]}>
427
+ <Text key={w.long} numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.weekday, ff, { color: colors.mutedForeground }]}>
411
428
  {w.short}
412
429
  </Text>
413
430
  ))}
@@ -420,7 +437,7 @@ export function Calendar(props: CalendarProps) {
420
437
  styles.leaving,
421
438
  {
422
439
  opacity: slide.interpolate({ inputRange: [0, 0.7], outputRange: [1, 0], extrapolate: 'clamp' }),
423
- transform: [{ translateX: slide.interpolate({ inputRange: [0, 1], outputRange: [0, -leaving.dir * W * 0.6] }) }],
440
+ transform: [{ translateX: slide.interpolate({ inputRange: [0, 1], outputRange: [0, reduce ? 0 : -leaving.dir * W * 0.6] }) }],
424
441
  },
425
442
  ]}
426
443
  >
@@ -444,7 +461,7 @@ export function Calendar(props: CalendarProps) {
444
461
  { minHeight: gridH ? gridH + 28 : undefined },
445
462
  {
446
463
  opacity: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
447
- transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) }],
464
+ transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.94, 1] }) }],
448
465
  },
449
466
  ]}
450
467
  >
@@ -486,7 +503,9 @@ export function Calendar(props: CalendarProps) {
486
503
  },
487
504
  ]}
488
505
  >
489
- <Text style={[styles.pickText, { color: selected ? readableOn(tint) : isNow ? tint : colors.foreground }]}>{label}</Text>
506
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.pickText, ff, { color: selected ? readableOn(tint) : isNow ? tint : colors.foreground }]}>
507
+ {label}
508
+ </Text>
490
509
  </View>
491
510
  )}
492
511
  </Pressable>
@@ -518,6 +537,7 @@ interface MonthGridProps {
518
537
  manyKeys?: string;
519
538
  tint: string;
520
539
  fullLabel: (d: Date) => string;
540
+ reduce: boolean;
521
541
  onDayPress: (d: Date) => void;
522
542
  }
523
543
 
@@ -539,6 +559,7 @@ const MonthGrid = memo(function MonthGrid({
539
559
  manyKeys,
540
560
  tint,
541
561
  fullLabel,
562
+ reduce,
542
563
  onDayPress,
543
564
  }: MonthGridProps) {
544
565
  const weeks = useMemo(() => buildWeeks(month, weekStartsOn, fixedWeeks), [month, weekStartsOn, fixedWeeks]);
@@ -593,6 +614,7 @@ const MonthGrid = memo(function MonthGrid({
593
614
  highlight={highlightedDates?.[key]}
594
615
  tile={tiles && inMonth && !inBand}
595
616
  tint={tint}
617
+ reduce={reduce}
596
618
  onPress={onDayPress}
597
619
  />
598
620
  );
@@ -618,6 +640,7 @@ interface DayCellProps {
618
640
  highlight?: string;
619
641
  tile: boolean;
620
642
  tint: string;
643
+ reduce: boolean;
621
644
  onPress: (d: Date) => void;
622
645
  }
623
646
 
@@ -636,15 +659,19 @@ const DayCell = memo(function DayCell({
636
659
  highlight,
637
660
  tile,
638
661
  tint,
662
+ reduce,
639
663
  onPress,
640
664
  }: DayCellProps) {
641
- const { colors, radius } = useKit();
665
+ const { colors, radius, font } = useKit();
666
+ const ff = font.family ? { fontFamily: font.family } : null;
642
667
  const r = tileRadius(radius.md);
643
668
  const pop = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: circle scale + opacity
644
669
  const bandIn = useRef(new Animated.Value(band === 'none' ? 0 : 1)).current; // native: band opacity
645
670
  useEffect(() => {
646
- Animated.spring(pop, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
647
- }, [selected, pop]);
671
+ // Reduce Motion: the selection fills in with a quick fade, no scale pop.
672
+ if (reduce) Animated.timing(pop, { toValue: selected ? 1 : 0, duration: 150, useNativeDriver: true }).start();
673
+ else Animated.spring(pop, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
674
+ }, [selected, pop, reduce]);
648
675
  useEffect(() => {
649
676
  Animated.timing(bandIn, { toValue: band === 'none' ? 0 : 1, duration: motion.normal, useNativeDriver: true }).start();
650
677
  }, [band, bandIn]);
@@ -711,14 +738,16 @@ const DayCell = memo(function DayCell({
711
738
  borderRadius: r,
712
739
  backgroundColor: tint,
713
740
  opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [0, 1], extrapolate: 'clamp' }),
714
- transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) }],
741
+ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.45, 1] }) }],
715
742
  },
716
743
  ]}
717
744
  />
718
745
  <View pointerEvents="none" style={styles.label}>
719
746
  <Text
747
+ maxFontSizeMultiplier={FONT_CAP}
720
748
  style={[
721
749
  styles.day,
750
+ ff,
722
751
  { color: textColor },
723
752
  (today || selected) && styles.dayStrong,
724
753
  outside && !selected && styles.outside,
@@ -768,11 +797,15 @@ function NavButton({ dir, disabled, onPress }: { dir: 1 | -1; disabled: boolean;
768
797
  );
769
798
  }
770
799
 
771
- function Chevron({ turned, color }: { turned: boolean; color: string }) {
800
+ function Chevron({ turned, color, reduce }: { turned: boolean; color: string; reduce: boolean }) {
772
801
  const turn = useRef(new Animated.Value(turned ? 1 : 0)).current; // native
773
802
  useEffect(() => {
803
+ if (reduce) {
804
+ turn.setValue(turned ? 1 : 0);
805
+ return;
806
+ }
774
807
  Animated.spring(turn, { toValue: turned ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
775
- }, [turned, turn]);
808
+ }, [turned, turn, reduce]);
776
809
  return (
777
810
  <Animated.View style={{ transform: [{ rotate: turn.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) }] }}>
778
811
  <Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
@@ -782,6 +815,22 @@ function Chevron({ turned, color }: { turned: boolean; color: string }) {
782
815
  );
783
816
  }
784
817
 
818
+ function useReduceMotion() {
819
+ const [reduce, setReduce] = useState(false);
820
+ useEffect(() => {
821
+ let alive = true;
822
+ AccessibilityInfo.isReduceMotionEnabled()
823
+ .then((r) => alive && setReduce(r))
824
+ .catch(() => {});
825
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
826
+ return () => {
827
+ alive = false;
828
+ sub.remove();
829
+ };
830
+ }, []);
831
+ return reduce;
832
+ }
833
+
785
834
  const styles = StyleSheet.create({
786
835
  root: { alignSelf: 'stretch' },
787
836
  header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, minHeight: 40 },
@@ -789,7 +838,7 @@ const styles = StyleSheet.create({
789
838
  title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.3, flexShrink: 1 },
790
839
  navs: { flexDirection: 'row', alignItems: 'center', gap: 6 },
791
840
  nav: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
792
- todayBtn: { height: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
841
+ todayBtn: { minHeight: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
793
842
  todayText: { fontSize: 13, fontWeight: '400' },
794
843
  weekRow: { flexDirection: 'row', marginBottom: 6 },
795
844
  weekday: { flex: 1, textAlign: 'center', fontSize: 13, fontWeight: '400', letterSpacing: 0.2 },
@@ -817,6 +866,6 @@ const styles = StyleSheet.create({
817
866
  dot: { width: 5, height: 5, borderRadius: 2.5 },
818
867
  pickGrid: { flexDirection: 'row', flexWrap: 'wrap', alignContent: 'center', paddingTop: 4 },
819
868
  pickCell: { width: '33.333%', height: 56, alignItems: 'center', justifyContent: 'center' },
820
- pickInner: { minWidth: 76, height: 40, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5 },
869
+ pickInner: { minWidth: 76, minHeight: 40, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5 },
821
870
  pickText: { fontSize: 15, fontWeight: '400' },
822
871
  });
@@ -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
  };
@@ -1,5 +1,6 @@
1
- import React, { createContext, useContext, useId, useRef, useState } from 'react';
1
+ import React, { createContext, useContext, useEffect, useId, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Image,
5
6
  Platform,
@@ -67,6 +68,7 @@ export function Card({
67
68
  const interactive = !!(onPress || onLongPress || pressable);
68
69
  const corner = radius.scale(18);
69
70
  const gid = 'card' + useId().replace(/[^a-zA-Z0-9]/g, '');
71
+ const reduce = useReduceMotion();
70
72
 
71
73
  const scale = useRef(new Animated.Value(1)).current;
72
74
  const glow = useRef(new Animated.Value(0)).current;
@@ -87,18 +89,20 @@ export function Card({
87
89
 
88
90
  const border = (surfaces[variant].borderWidth as number | undefined) ?? 0;
89
91
 
92
+ // Reduce Motion: no press squish and the spotlight doesn't grow; the glow still fades in and out.
90
93
  const pressIn = (e: GestureResponderEvent) => {
91
- Animated.spring(scale, { toValue: 0.975, useNativeDriver: true, ...motion.snappy }).start();
94
+ if (!reduce) Animated.spring(scale, { toValue: 0.975, useNativeDriver: true, ...motion.snappy }).start();
92
95
  if (highlight === 'none') return;
93
96
  moveSpot(e);
94
97
  Animated.spring(glow, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
95
98
  };
96
99
  const pressOut = () => {
97
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
98
- if (highlight !== 'none') Animated.timing(glow, { toValue: 0, duration: 420, useNativeDriver: true }).start();
100
+ if (reduce) scale.setValue(1);
101
+ else Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
102
+ if (highlight !== 'none') Animated.timing(glow, { toValue: 0, duration: reduce ? 150 : 420, useNativeDriver: true }).start();
99
103
  };
100
104
  // Touch coordinates are relative to whichever child was hit, so work from page coordinates.
101
- const clipRef = useRef<View>(null);
105
+ const clipRef = useRef<ComponentRef<typeof View>>(null);
102
106
  const origin = useRef({ x: 0, y: 0 });
103
107
  const placeSpot = (pageX: number, pageY: number) =>
104
108
  spot.setValue({ x: pageX - origin.current.x - spotD / 2, y: pageY - origin.current.y - spotD / 2 });
@@ -125,7 +129,7 @@ export function Card({
125
129
  transform: [
126
130
  { translateX: spot.x },
127
131
  { translateY: spot.y },
128
- { scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) },
132
+ { scale: glow.interpolate({ inputRange: [0, 1], outputRange: reduce ? [1, 1] : [0.4, 1] }) },
129
133
  ],
130
134
  },
131
135
  ]}
@@ -215,18 +219,20 @@ export interface CardTextProps {
215
219
  }
216
220
 
217
221
  export function CardTitle({ children, numberOfLines, style }: CardTextProps) {
218
- const { colors } = useKit();
222
+ const { colors, font } = useKit();
223
+ const ff = font.family ? { fontFamily: font.family } : null;
219
224
  return (
220
- <Text accessibilityRole="header" numberOfLines={numberOfLines} style={[styles.title, { color: colors.cardForeground }, style]}>
225
+ <Text accessibilityRole="header" numberOfLines={numberOfLines} style={[styles.title, ff, { color: colors.cardForeground }, style]}>
221
226
  {children}
222
227
  </Text>
223
228
  );
224
229
  }
225
230
 
226
231
  export function CardDescription({ children, numberOfLines, style }: CardTextProps) {
227
- const { colors } = useKit();
232
+ const { colors, font } = useKit();
233
+ const ff = font.family ? { fontFamily: font.family } : null;
228
234
  return (
229
- <Text numberOfLines={numberOfLines} style={[styles.description, { color: colors.mutedForeground }, style]}>
235
+ <Text numberOfLines={numberOfLines} style={[styles.description, ff, { color: colors.mutedForeground }, style]}>
230
236
  {children}
231
237
  </Text>
232
238
  );
@@ -286,6 +292,22 @@ function shadow(opacity: number, y: number, blur: number, elevation = 2): ViewSt
286
292
  return { shadowColor: '#000', shadowOpacity: opacity, shadowRadius: blur / 2, shadowOffset: { width: 0, height: y }, elevation };
287
293
  }
288
294
 
295
+ function useReduceMotion() {
296
+ const [reduce, setReduce] = useState(false);
297
+ useEffect(() => {
298
+ let alive = true;
299
+ AccessibilityInfo.isReduceMotionEnabled()
300
+ .then((r) => alive && setReduce(r))
301
+ .catch(() => {});
302
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
303
+ return () => {
304
+ alive = false;
305
+ sub.remove();
306
+ };
307
+ }, []);
308
+ return reduce;
309
+ }
310
+
289
311
  const styles = StyleSheet.create({
290
312
  clip: { overflow: 'hidden' },
291
313
  spot: { position: 'absolute', top: 0, left: 0 },
@@ -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
  };