@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,10 +1,12 @@
1
1
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Pressable,
5
6
  StyleSheet,
6
7
  Text,
7
8
  View,
9
+ useWindowDimensions,
8
10
  type AccessibilityActionEvent,
9
11
  type LayoutChangeEvent,
10
12
  type StyleProp,
@@ -65,6 +67,10 @@ export interface StepperProps {
65
67
  valueStyle?: StyleProp<TextStyle>;
66
68
  }
67
69
 
70
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
71
+ // content text scales all the way.
72
+ const FONT_CAP = 1.35;
73
+
68
74
  /** Every dimension in one table so the sizes read clearly different. */
69
75
  const SIZES: Record<StepperSize | 'xs', { btn: number; font: number; icon: number; num: number }> = {
70
76
  xs: { btn: 28, font: 15, icon: 12, num: 26 },
@@ -118,7 +124,9 @@ export function Stepper({
118
124
  buttonStyle,
119
125
  valueStyle,
120
126
  }: StepperProps) {
121
- const { colors, radius } = useKit();
127
+ const { colors, radius, font } = useKit();
128
+ const ff = font.family ? { fontFamily: font.family } : null;
129
+ const reduce = useReduceMotion();
122
130
  const variant: StepperVariant = variantProp in LEGACY ? LEGACY[variantProp as StepperLegacyVariant] : (variantProp as StepperVariant);
123
131
  const vertical = variant !== 'cart' && (orientation ? orientation === 'vertical' : variantProp === 'vertical');
124
132
  const tint = accent ?? colors.accent;
@@ -133,9 +141,11 @@ export function Stepper({
133
141
  const [track, setTrack] = useState({ v: current, dir: 1 });
134
142
  if (track.v !== current) setTrack({ v: current, dir: current > track.v ? 1 : -1 });
135
143
 
136
- // Press past a limit: the number nudges toward the wall and springs back.
144
+ // Press past a limit: the number nudges toward the wall and springs back (not with Reduce Motion;
145
+ // the dimmed button already says it's the limit).
137
146
  const bump = useRef(new Animated.Value(0)).current;
138
147
  const nudge = (dir: 1 | -1) => {
148
+ if (reduce) return;
139
149
  Animated.sequence([
140
150
  Animated.timing(bump, { toValue: dir * 4, duration: 60, useNativeDriver: true }),
141
151
  Animated.spring(bump, { toValue: 0, useNativeDriver: true, ...motion.bouncy }),
@@ -199,6 +209,7 @@ export function Stepper({
199
209
  fontSize,
200
210
  lineHeight: Math.round(fontSize * 1.3),
201
211
  color: numColor,
212
+ ...ff,
202
213
  ...flatValue,
203
214
  };
204
215
 
@@ -236,6 +247,7 @@ export function Stepper({
236
247
  limit={dir === 1 ? atMax : atMin}
237
248
  disabled={!!disabled}
238
249
  label={a11y ?? `${dir === 1 ? 'Increase' : 'Decrease'}${title ? ` ${title}` : ''}`}
250
+ reduce={reduce}
239
251
  style={buttonStyle}
240
252
  onPressIn={() => startRepeat(dir)}
241
253
  onPressOut={stopRepeat}
@@ -244,9 +256,9 @@ export function Stepper({
244
256
 
245
257
  const number = (
246
258
  <View style={styles.roll}>
247
- <RollingText text={text} dir={track.dir} style={numStyle} />
259
+ <RollingText text={text} dir={track.dir} style={numStyle} reduce={reduce} />
248
260
  {suffixText ? (
249
- <Text style={[styles.num, styles.suffix, numStyle, { fontSize: fontSize * 0.82, color: isCart ? alpha(onCart, 0.8) : colors.mutedForeground }]}>
261
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, styles.suffix, numStyle, { fontSize: fontSize * 0.82, color: isCart ? alpha(onCart, 0.8) : colors.mutedForeground }]}>
250
262
  {suffixText}
251
263
  </Text>
252
264
  ) : null}
@@ -285,6 +297,7 @@ export function Stepper({
285
297
  addLabel={addLabel}
286
298
  bump={bump}
287
299
  disabled={!!disabled}
300
+ reduce={reduce}
288
301
  dec={button(-1, removes ? ICON.trash : ICON.minus, removes ? `Remove${title ? ` ${title}` : ''}` : undefined)}
289
302
  inc={button(1)}
290
303
  onAdd={() => startRepeat(1)}
@@ -298,7 +311,7 @@ export function Stepper({
298
311
  styles.value,
299
312
  vertical
300
313
  ? { minHeight: s.num, minWidth: s.btn, paddingVertical: 2 }
301
- : { minWidth: s.num, height: s.btn, paddingHorizontal: variant === 'outline' ? 8 : 4 },
314
+ : { minWidth: s.num, minHeight: s.btn, paddingHorizontal: variant === 'outline' ? 8 : 4 },
302
315
  { transform: [vertical ? { translateY: Animated.multiply(bump, -1) } : { translateX: bump }] },
303
316
  ]}
304
317
  >
@@ -312,8 +325,8 @@ export function Stepper({
312
325
  return (
313
326
  <View style={[styles.labelled, style]}>
314
327
  <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}
328
+ {title ? <Text style={[styles.label, ff, { color: colors.foreground }]}>{title}</Text> : null}
329
+ {description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
317
330
  </View>
318
331
  {control}
319
332
  </View>
@@ -338,6 +351,7 @@ function CartControl({
338
351
  addLabel,
339
352
  bump,
340
353
  disabled,
354
+ reduce,
341
355
  dec,
342
356
  inc,
343
357
  onAdd,
@@ -356,11 +370,14 @@ function CartControl({
356
370
  addLabel?: string;
357
371
  bump: Animated.Value;
358
372
  disabled: boolean;
373
+ reduce: boolean;
359
374
  dec: React.ReactNode;
360
375
  inc: React.ReactNode;
361
376
  onAdd: () => void;
362
377
  onAddEnd: () => void;
363
378
  } & React.ComponentProps<typeof View>) {
379
+ const { font } = useKit();
380
+ const ff = font.family ? { fontFamily: font.family } : null;
364
381
  // Natural widths, measured off-screen so the spring always lands on the real size.
365
382
  const [textW, setTextW] = useState(0);
366
383
  const [addW, setAddW] = useState(0);
@@ -376,18 +393,20 @@ function CartControl({
376
393
 
377
394
  const measured = textW > 0 && (!addLabel || addW > 0);
378
395
  useEffect(() => {
379
- // Snap until the rulers have reported; after that every change springs.
380
- if (!settled.current) {
396
+ // Snap until the rulers have reported; after that every change springs (or snaps, with Reduce Motion).
397
+ if (!settled.current || reduce) {
381
398
  width.setValue(target);
382
399
  settled.current = measured;
383
400
  return;
384
401
  }
385
402
  Animated.spring(width, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();
386
- }, [target, measured, width]);
403
+ }, [target, measured, width, reduce]);
387
404
 
388
405
  useEffect(() => {
389
- Animated.spring(open, { toValue: collapsed ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
390
- }, [collapsed, open]);
406
+ // Reduce Motion: − and the number only fade in (their slide is flattened below).
407
+ if (reduce) Animated.timing(open, { toValue: collapsed ? 0 : 1, duration: 150, useNativeDriver: true }).start();
408
+ else Animated.spring(open, { toValue: collapsed ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
409
+ }, [collapsed, open, reduce]);
391
410
 
392
411
  const measure = (set: (n: number) => void) => (e: LayoutChangeEvent) => set(Math.ceil(e.nativeEvent.layout.width));
393
412
 
@@ -403,15 +422,15 @@ function CartControl({
403
422
  {/* Off-screen rulers inside a wide box, so nothing squeezes them. */}
404
423
  <View pointerEvents="none" style={[styles.ruler, styles.hidden]}>
405
424
  <View onLayout={measure(setTextW)} style={styles.row}>
406
- <Text style={[styles.num, numStyle]}>{text}</Text>
407
- {suffixText ? <Text style={[styles.num, styles.suffix, numStyle, { fontSize: (numStyle.fontSize ?? s.font) * 0.82 }]}>{suffixText}</Text> : null}
425
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, numStyle]}>{text}</Text>
426
+ {suffixText ? <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, styles.suffix, numStyle, { fontSize: (numStyle.fontSize ?? s.font) * 0.82 }]}>{suffixText}</Text> : null}
408
427
  </View>
409
428
  </View>
410
429
 
411
430
  <View style={[styles.cartRow, { width: openW }]} pointerEvents="box-none">
412
431
  <Animated.View
413
432
  pointerEvents={collapsed ? 'none' : 'auto'}
414
- style={[styles.row, { opacity: open, transform: [{ translateX: open.interpolate({ inputRange: [0, 1], outputRange: [s.btn * 0.5, 0] }) }] }]}
433
+ style={[styles.row, { opacity: open, transform: [{ translateX: open.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : s.btn * 0.5, 0] }) }] }]}
415
434
  >
416
435
  {dec}
417
436
  <Animated.View style={[styles.value, { width: valueW, height: s.btn, transform: [{ translateX: bump }] }]}>{number}</Animated.View>
@@ -437,8 +456,8 @@ function CartControl({
437
456
  onPressOut={onAddEnd}
438
457
  style={({ pressed }) => [styles.add, { height: s.btn, paddingHorizontal: Math.round(s.btn * 0.36) }, pressed && { backgroundColor: alpha(fg, 0.16) }]}
439
458
  >
440
- <Glyph d={ICON.plus} size={s.icon} color={fg} />
441
- <Text style={[styles.addText, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
459
+ <Glyph d={ICON.plus} size={s.icon} color={fg} reduce={reduce} />
460
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.addText, ff, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
442
461
  </Pressable>
443
462
  </Animated.View>
444
463
  </View>
@@ -466,6 +485,7 @@ function StepButton({
466
485
  limit,
467
486
  disabled,
468
487
  label,
488
+ reduce,
469
489
  style,
470
490
  onPressIn,
471
491
  onPressOut,
@@ -481,6 +501,7 @@ function StepButton({
481
501
  limit: boolean;
482
502
  disabled: boolean;
483
503
  label: string;
504
+ reduce: boolean;
484
505
  style?: StyleProp<ViewStyle>;
485
506
  onPressIn: () => void;
486
507
  onPressOut: () => void;
@@ -488,7 +509,11 @@ function StepButton({
488
509
  // Scale and dim share one native-driven view; nothing JS-driven touches it.
489
510
  const scale = useRef(new Animated.Value(1)).current;
490
511
  const dim = useRef(new Animated.Value(limit ? 0.32 : 1)).current;
491
- const spring = (to: number) => Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
512
+ // Reduce Motion: no press squeeze; the pressed background still shows the press.
513
+ const spring = (to: number) => {
514
+ if (reduce) scale.setValue(1);
515
+ else Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
516
+ };
492
517
 
493
518
  useEffect(() => {
494
519
  Animated.timing(dim, { toValue: limit ? 0.32 : 1, duration: motion.fast, useNativeDriver: true }).start();
@@ -529,22 +554,23 @@ function StepButton({
529
554
  style,
530
555
  ]}
531
556
  >
532
- <Glyph d={glyph} size={icon} color={fg} />
557
+ <Glyph d={glyph} size={icon} color={fg} reduce={reduce} />
533
558
  </Pressable>
534
559
  </Animated.View>
535
560
  );
536
561
  }
537
562
 
538
563
  /** Stroked icon; pops in when its shape changes (− ↔ trash). */
539
- function Glyph({ d, size, color }: { d: string; size: number; color: string }) {
564
+ function Glyph({ d, size, color, reduce }: { d: string; size: number; color: string; reduce: boolean }) {
540
565
  const pop = useRef(new Animated.Value(1)).current;
541
566
  const prev = useRef(d);
542
567
  useEffect(() => {
543
568
  if (prev.current === d) return;
544
569
  prev.current = d;
570
+ if (reduce) return;
545
571
  pop.setValue(0.55);
546
572
  Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
547
- }, [d, pop]);
573
+ }, [d, pop, reduce]);
548
574
  const trash = d === ICON.trash;
549
575
  return (
550
576
  <Animated.View style={{ transform: [{ scale: pop }] }}>
@@ -555,37 +581,48 @@ function Glyph({ d, size, color }: { d: string; size: number; color: string }) {
555
581
  );
556
582
  }
557
583
 
558
- /** Text where every changed character slides out and its replacement slides in. */
559
- function RollingText({ text, dir, style }: { text: string; dir: number; style: TextStyle }) {
584
+ /** Text where every changed character slides out and its replacement slides in (swaps at once with Reduce Motion). */
585
+ function RollingText({ text, dir, style, reduce }: { text: string; dir: number; style: TextStyle; reduce: boolean }) {
560
586
  const chars = [...text];
561
587
  return (
562
588
  <View style={styles.roll}>
563
589
  {chars.map((c, i) => (
564
590
  // Keyed from the right so the ones digit stays the ones digit when the number grows.
565
- <RollingChar key={chars.length - i} char={c} dir={dir} style={style} />
591
+ <RollingChar key={chars.length - i} char={c} dir={dir} style={style} reduce={reduce} />
566
592
  ))}
567
593
  </View>
568
594
  );
569
595
  }
570
596
 
571
- function RollingChar({ char, dir, style }: { char: string; dir: number; style: TextStyle }) {
597
+ function RollingChar({ char, dir, style, reduce }: { char: string; dir: number; style: TextStyle; reduce: boolean }) {
572
598
  const [pair, setPair] = useState<{ cur: string; prev: string | null; dir: number }>({ cur: char, prev: null, dir });
573
599
  if (pair.cur !== char) setPair({ cur: char, prev: pair.cur, dir });
574
600
  const t = useRef(new Animated.Value(1)).current;
575
- const h = (style.lineHeight as number) ?? 24;
601
+ // The digit window follows the text's own (capped) Dynamic Type scale, so a scaled digit isn't cropped.
602
+ const { fontScale } = useWindowDimensions();
603
+ const h = ((style.lineHeight as number) ?? 24) * Math.min(fontScale, FONT_CAP);
576
604
 
605
+ // Each change rolls once (a Reduce Motion toggle alone doesn't replay it).
606
+ const played = useRef(pair);
577
607
  useLayoutEffect(() => {
578
- if (pair.prev === null) return;
608
+ if (pair.prev === null || played.current === pair) return;
609
+ played.current = pair;
610
+ if (reduce) {
611
+ // At 1 the old character is gone and the new one is in place: the final value, no roll.
612
+ t.setValue(1);
613
+ return;
614
+ }
579
615
  t.setValue(0);
580
616
  Animated.spring(t, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
581
- }, [pair, t]);
617
+ }, [pair, t, reduce]);
582
618
 
583
619
  const d = pair.dir * h * 0.8;
584
620
  return (
585
621
  <View style={[styles.charBox, { height: h }]}>
586
- <Text style={[styles.num, style, styles.hidden]}>{pair.cur}</Text>
622
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, style, styles.hidden]}>{pair.cur}</Text>
587
623
  {pair.prev !== null ? (
588
624
  <Animated.Text
625
+ maxFontSizeMultiplier={FONT_CAP}
589
626
  style={[
590
627
  styles.num,
591
628
  styles.layer,
@@ -603,6 +640,7 @@ function RollingChar({ char, dir, style }: { char: string; dir: number; style: T
603
640
  </Animated.Text>
604
641
  ) : null}
605
642
  <Animated.Text
643
+ maxFontSizeMultiplier={FONT_CAP}
606
644
  style={[
607
645
  styles.num,
608
646
  styles.layer,
@@ -619,6 +657,22 @@ function RollingChar({ char, dir, style }: { char: string; dir: number; style: T
619
657
  );
620
658
  }
621
659
 
660
+ function useReduceMotion() {
661
+ const [reduce, setReduce] = useState(false);
662
+ useEffect(() => {
663
+ let alive = true;
664
+ AccessibilityInfo.isReduceMotionEnabled()
665
+ .then((r) => alive && setReduce(r))
666
+ .catch(() => {});
667
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
668
+ return () => {
669
+ alive = false;
670
+ sub.remove();
671
+ };
672
+ }, []);
673
+ return reduce;
674
+ }
675
+
622
676
  const styles = StyleSheet.create({
623
677
  // Hugs its content in a column without hijacking the cross-axis alignment of a row.
624
678
  self: { alignItems: 'flex-start', justifyContent: 'center' },
@@ -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,6 @@
1
1
  import * as Haptics from 'expo-haptics';
2
2
  import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
3
- import { Animated, PanResponder, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { AccessibilityInfo, Animated, PanResponder, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
4
4
  import Svg, { Circle, Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
5
5
 
6
6
  import { alpha, motion, readableOn, useKit } from '@/lib/kit';
@@ -63,8 +63,10 @@ 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, '');
69
+ const reduce = useReduceMotion();
68
70
  const tint = accent ?? colors.accent;
69
71
  const [inner, setInner] = useState(defaultValue);
70
72
  const on = value ?? inner;
@@ -87,18 +89,21 @@ export function Switch({
87
89
  const stretch = useRef(new Animated.Value(0)).current;
88
90
  const glyph = useRef(new Animated.Value(on ? 1 : 0)).current;
89
91
  const halo = useRef(new Animated.Value(0)).current;
92
+ // Reduce Motion: the thumb jumps, so the colours follow this instead and still fade (JS driver).
93
+ const tone = useRef(new Animated.Value(on ? 1 : 0)).current;
90
94
 
91
95
  useEffect(() => {
92
- Animated.spring(pos, { toValue: on ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
93
- Animated.spring(glyph, { toValue: on ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
94
- }, [on, pos, glyph]);
95
-
96
- // Halo: 0 hidden · 1 held (glow around the thumb) · 2 released (expands and fades out).
97
- const press = (to: number) => {
98
- Animated.spring(stretch, { toValue: to, useNativeDriver: false, ...(to ? motion.snappy : motion.bouncy) }).start();
99
- if (to) Animated.spring(halo, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
100
- else Animated.timing(halo, { toValue: 2, duration: 420, useNativeDriver: true }).start(({ finished }) => finished && halo.setValue(0));
101
- };
96
+ const to = on ? 1 : 0;
97
+ if (reduce) {
98
+ pos.setValue(to);
99
+ glyph.setValue(to);
100
+ Animated.timing(tone, { toValue: to, duration: 150, useNativeDriver: false }).start();
101
+ return;
102
+ }
103
+ tone.setValue(to);
104
+ Animated.spring(pos, { toValue: to, useNativeDriver: false, ...motion.snappy }).start();
105
+ Animated.spring(glyph, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
106
+ }, [on, reduce, pos, glyph, tone]);
102
107
 
103
108
  const toggle = () => {
104
109
  if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
@@ -113,8 +118,24 @@ export function Switch({
113
118
  // Drag the thumb across: pos follows the finger, release settles on the nearer side.
114
119
  // A touch that never moves is a tap and just toggles. On web the wrapping Pressable also
115
120
  // gets a click after the gesture, so it ignores presses right after one (`endedAt`).
116
- const live = useRef({ on, toggle, travel: c1 - c0 });
117
- live.current = { on, toggle, travel: c1 - c0 };
121
+ const live = useRef({ on, toggle, travel: c1 - c0, reduce });
122
+ live.current = { on, toggle, travel: c1 - c0, reduce };
123
+
124
+ // Halo: 0 hidden · 1 held (glow around the thumb) · 2 released (expands and fades out).
125
+ // Reduce Motion: no squash & stretch, and the halo only fades (its scale is pinned below).
126
+ const press = (to: number) => {
127
+ const still = live.current.reduce;
128
+ if (!still) Animated.spring(stretch, { toValue: to, useNativeDriver: false, ...(to ? motion.snappy : motion.bouncy) }).start();
129
+ if (to) Animated.spring(halo, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
130
+ else
131
+ Animated.timing(halo, { toValue: 2, duration: still ? 150 : 420, useNativeDriver: true }).start(
132
+ ({ finished }) => finished && halo.setValue(0)
133
+ );
134
+ };
135
+ const settle = (to: number) => {
136
+ if (live.current.reduce) pos.setValue(to);
137
+ else Animated.spring(pos, { toValue: to, useNativeDriver: false, ...motion.snappy }).start();
138
+ };
118
139
  const drag = useRef({ from: 0, moved: false, endedAt: 0 });
119
140
  const responder = useMemo(
120
141
  () =>
@@ -137,12 +158,12 @@ export function Switch({
137
158
  press(0);
138
159
  if (!drag.current.moved) return flip();
139
160
  const next = clamp01(drag.current.from + g.dx / live.current.travel) > 0.5;
140
- Animated.spring(pos, { toValue: next ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
161
+ settle(next ? 1 : 0);
141
162
  if (next !== now) flip();
142
163
  },
143
164
  onPanResponderTerminate: () => {
144
165
  press(0);
145
- Animated.spring(pos, { toValue: live.current.on ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
166
+ settle(live.current.on ? 1 : 0);
146
167
  },
147
168
  }),
148
169
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -163,12 +184,13 @@ export function Switch({
163
184
  Animated.multiply(stretch, growH / 2)
164
185
  );
165
186
 
187
+ const paint = reduce ? tone : pos;
166
188
  const offTrack = material ? alpha(colors.mutedForeground, 0.12) : alpha(colors.foreground, dark ? 0.16 : 0.1);
167
- const trackBg = pos.interpolate({ inputRange: [0, 1], outputRange: [offTrack, tint], extrapolate: 'clamp' });
168
- const trackBorder = pos.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, tint], extrapolate: 'clamp' });
189
+ const trackBg = paint.interpolate({ inputRange: [0, 1], outputRange: [offTrack, tint], extrapolate: 'clamp' });
190
+ const trackBorder = paint.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, tint], extrapolate: 'clamp' });
169
191
  const onFg = readableOn(tint);
170
192
  const thumbBg = material
171
- ? pos.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, onFg], extrapolate: 'clamp' })
193
+ ? paint.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, onFg], extrapolate: 'clamp' })
172
194
  : '#ffffff';
173
195
 
174
196
  const iconSize = dOn * 0.62;
@@ -221,7 +243,7 @@ export function Switch({
221
243
  borderRadius: haloSize / 2,
222
244
  backgroundColor: alpha(on ? tint : colors.mutedForeground, 0.2),
223
245
  opacity: halo.interpolate({ inputRange: [0, 1, 2], outputRange: [0, 1, 0] }),
224
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1, 2], outputRange: [0.5, 1, 1.45] }) }],
246
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1, 2], outputRange: reduce ? [1, 1, 1] : [0.5, 1, 1.45] }) }],
225
247
  },
226
248
  ]}
227
249
  />
@@ -315,14 +337,30 @@ export function Switch({
315
337
  return (
316
338
  <Pressable {...a11y} style={[styles.row, { opacity: disabled ? 0.45 : 1 }, style]}>
317
339
  <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}
340
+ <Text style={[styles.label, ff, { color: colors.foreground }]}>{label}</Text>
341
+ {description ? <Text style={[styles.description, ff, { color: colors.mutedForeground }]}>{description}</Text> : null}
320
342
  </View>
321
343
  {control}
322
344
  </Pressable>
323
345
  );
324
346
  }
325
347
 
348
+ function useReduceMotion() {
349
+ const [reduce, setReduce] = useState(false);
350
+ useEffect(() => {
351
+ let alive = true;
352
+ AccessibilityInfo.isReduceMotionEnabled()
353
+ .then((r) => alive && setReduce(r))
354
+ .catch(() => {});
355
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
356
+ return () => {
357
+ alive = false;
358
+ sub.remove();
359
+ };
360
+ }, []);
361
+ return reduce;
362
+ }
363
+
326
364
  const styles = StyleSheet.create({
327
365
  row: { flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 44 },
328
366
  text: { flex: 1 },
@@ -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
1
  import * as Haptics from 'expo-haptics';
2
- import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import React, { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
3
3
  import {
4
4
  AccessibilityInfo,
5
5
  Animated,
@@ -64,8 +64,11 @@ type Box = { x: number; w: number };
64
64
 
65
65
  // Liquid move: the edge in the direction of travel leads, the other one trails behind.
66
66
  const LEAD = motion.snappy;
67
- const TRAIL = { damping: 20, stiffness: 150, mass: 0.9 };
67
+ const TRAIL = { damping: 24, stiffness: 150, mass: 0.9 };
68
68
  const UNDERLINE = 3;
69
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
70
+ // content text scales all the way.
71
+ const FONT_CAP = 1.35;
69
72
 
70
73
  /**
71
74
  * Underline tabs with swipeable content panes. The underline is measured to each label and moves
@@ -123,7 +126,7 @@ export function Tabs({
123
126
  const release = useRef({ v: 0, dragged: false });
124
127
  const paging = useRef(false); // pager spring in flight (its JS value is stale until it ends)
125
128
  const [settle, setSettle] = useState(0);
126
- const scrollRef = useRef<ScrollView>(null);
129
+ const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);
127
130
 
128
131
  const edgesOf = (i: number): [number, number] | null => {
129
132
  const t = tabs.current[i];
@@ -173,7 +176,7 @@ export function Tabs({
173
176
  Animated.timing(pan, { toValue: to, duration: 180, useNativeDriver: true }).start();
174
177
  } else {
175
178
  paging.current = true;
176
- Animated.spring(pan, { toValue: to, velocity: v, useNativeDriver: true, ...motion.snappy, damping: 24 }).start(
179
+ Animated.spring(pan, { toValue: to, velocity: v, useNativeDriver: true, ...motion.snappy, damping: 30 }).start(
177
180
  ({ finished }) => {
178
181
  if (finished) paging.current = false;
179
182
  }
@@ -425,7 +428,8 @@ interface TabProps {
425
428
  }
426
429
 
427
430
  function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLayout, onLabelLayout }: TabProps) {
428
- const { colors } = useKit();
431
+ const { colors, font } = useKit();
432
+ const ff = font.family ? { fontFamily: font.family } : null;
429
433
  const a = useRef(new Animated.Value(active ? 1 : 0)).current; // label colour (JS)
430
434
  const press = useRef(new Animated.Value(1)).current; // squish (native)
431
435
 
@@ -454,7 +458,7 @@ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLa
454
458
  hitSlop={slop ? { top: slop, bottom: slop } : undefined}
455
459
  style={[
456
460
  styles.tab,
457
- { height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },
461
+ { minHeight: height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },
458
462
  mode === 'fill' && styles.fill,
459
463
  mode === 'auto' && styles.grow,
460
464
  ]}
@@ -462,7 +466,7 @@ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLa
462
466
  {/* Measured: the underline spans exactly this (icon + label). */}
463
467
  <Animated.View onLayout={onLabelLayout} style={[styles.labelRow, { transform: [{ scale: press }] }]}>
464
468
  {icon}
465
- <Animated.Text numberOfLines={1} style={[styles.label, { color, fontSize }]}>
469
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[styles.label, ff, { color, fontSize }]}>
466
470
  {item.label}
467
471
  </Animated.Text>
468
472
  </Animated.View>
@@ -473,7 +477,8 @@ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLa
473
477
 
474
478
  /** Count bubble: accent when its tab is active, muted otherwise. Pops when the value changes. */
475
479
  function TabBadge({ value, active, tint, reduce }: { value: number | string; active: boolean; tint: string; reduce: boolean }) {
476
- const { colors, radius, dark } = useKit();
480
+ const { colors, radius, dark, font } = useKit();
481
+ const ff = font.family ? { fontFamily: font.family } : null;
477
482
  const s = useRef(new Animated.Value(1)).current;
478
483
  const first = useRef(true);
479
484
  useEffect(() => {
@@ -496,7 +501,7 @@ function TabBadge({ value, active, tint, reduce }: { value: number | string; act
496
501
  },
497
502
  ]}
498
503
  >
499
- <Animated.Text style={[styles.badgeText, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>
504
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.badgeText, ff, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>
500
505
  </Animated.View>
501
506
  );
502
507
  }
@@ -531,7 +536,7 @@ const styles = StyleSheet.create({
531
536
  labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
532
537
  label: { fontWeight: '400', letterSpacing: -0.1 },
533
538
  underline: { position: 'absolute', bottom: 0, height: UNDERLINE },
534
- badge: { minWidth: 18, height: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },
539
+ badge: { minWidth: 18, minHeight: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },
535
540
  badgeText: { fontSize: 11, fontWeight: '400', fontVariant: ['tabular-nums'] },
536
541
  viewport: { overflow: 'hidden' },
537
542
  pager: { flexDirection: 'row', alignItems: 'flex-start' },
@@ -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
  };