@expokit/cli 0.4.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 (92) hide show
  1. package/bin/lib.mjs +1 -1
  2. package/package.json +1 -1
  3. package/registry/_docs/accordion.json +1 -1
  4. package/registry/_docs/avatar.json +6 -6
  5. package/registry/_docs/badge.json +3 -3
  6. package/registry/_docs/bottom-nav.json +6 -6
  7. package/registry/_docs/bottom-sheet.json +3 -3
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/button-3d.json +2 -2
  10. package/registry/_docs/button.json +2 -2
  11. package/registry/_docs/calendar.json +5 -5
  12. package/registry/_docs/card.json +1 -1
  13. package/registry/_docs/chip-group.json +1 -1
  14. package/registry/_docs/confetti.json +3 -3
  15. package/registry/_docs/date-picker.json +4 -4
  16. package/registry/_docs/drawer.json +4 -4
  17. package/registry/_docs/flip-card.json +4 -4
  18. package/registry/_docs/floating-input.json +1 -1
  19. package/registry/_docs/grain.json +2 -2
  20. package/registry/_docs/isometric-bar.json +6 -0
  21. package/registry/_docs/liquidglass-tab.json +1 -1
  22. package/registry/_docs/otp-input.json +1 -1
  23. package/registry/_docs/payment-card.json +3 -3
  24. package/registry/_docs/pixel-bar.json +6 -0
  25. package/registry/_docs/polar-area.json +6 -0
  26. package/registry/_docs/progress-steps.json +4 -4
  27. package/registry/_docs/progress.json +2 -2
  28. package/registry/_docs/radio-group.json +1 -1
  29. package/registry/_docs/search-bar.json +3 -3
  30. package/registry/_docs/segmented-control.json +1 -1
  31. package/registry/_docs/select.json +6 -6
  32. package/registry/_docs/skeleton.json +1 -1
  33. package/registry/_docs/slider.json +6 -6
  34. package/registry/_docs/spinner.json +2 -2
  35. package/registry/_docs/stateful-button.json +1 -1
  36. package/registry/_docs/stepper.json +3 -3
  37. package/registry/_docs/tabs.json +2 -2
  38. package/registry/_docs/text-animation.json +4 -4
  39. package/registry/_docs/textarea.json +1 -1
  40. package/registry/_docs/tilt-card.json +3 -3
  41. package/registry/_docs/time-picker.json +6 -6
  42. package/registry/_docs/timeline-chart.json +6 -0
  43. package/registry/_docs/timeline.json +4 -4
  44. package/registry/_docs/treemap.json +6 -0
  45. package/registry/_docs/waterfall.json +6 -0
  46. package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
  47. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
  48. package/registry/alert/files/src/components/ui/alert.tsx +5 -2
  49. package/registry/alert/files/src/components/ui/button.tsx +32 -4
  50. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  51. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
  52. package/registry/button/files/src/components/ui/button.tsx +32 -4
  53. package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
  54. package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
  55. package/registry/card/files/src/components/ui/card.tsx +25 -5
  56. package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
  57. package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
  58. package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
  59. package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
  60. package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
  61. package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
  62. package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
  63. package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
  64. package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
  65. package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
  66. package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
  67. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
  68. package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
  69. package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
  70. package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
  71. package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
  72. package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
  73. package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
  74. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
  75. package/registry/progress/files/src/components/ui/progress.tsx +9 -2
  76. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
  77. package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
  78. package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
  79. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
  80. package/registry/select/files/src/components/ui/select.tsx +158 -83
  81. package/registry/slider/files/src/components/ui/slider.tsx +53 -25
  82. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  83. package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
  84. package/registry/switch/files/src/components/ui/switch.tsx +56 -19
  85. package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
  86. package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
  87. package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
  88. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
  89. package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
  90. package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
  91. package/registry/toast/files/src/components/ui/button.tsx +32 -4
  92. package/registry/toast/files/src/components/ui/toast.tsx +15 -6
@@ -1,5 +1,6 @@
1
1
  import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Platform,
@@ -85,6 +86,9 @@ export const FLOATING_INPUT_SIZES = {
85
86
  md: { height: 54, font: 16, floated: 12.5, padX: 16, icon: 18, radius: 0 },
86
87
  lg: { height: 64, font: 18, floated: 13.5, padX: 18, icon: 20, radius: 4 },
87
88
  };
89
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
90
+ // content text scales all the way.
91
+ const FONT_CAP = 1.35;
88
92
  const HALO = 4;
89
93
  /** Font size of the static label used when `floatingLabel` is false. */
90
94
  const LABEL_ABOVE_FONT = { sm: 13, md: 14, lg: 14.5 };
@@ -166,6 +170,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
166
170
  ref
167
171
  ) {
168
172
  const { colors, radius, font } = useKit();
173
+ const reduce = useReduceMotion();
169
174
  const ff = font.family ? { fontFamily: font.family } : null;
170
175
  const S = FLOATING_INPUT_SIZES[size];
171
176
  // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
@@ -200,29 +205,36 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
200
205
  const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
201
206
 
202
207
  useEffect(() => {
203
- Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
204
- }, [floating, float]);
208
+ if (reduce) float.setValue(floating ? 1 : 0);
209
+ else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
210
+ }, [floating, float, reduce]);
205
211
 
206
212
  useEffect(() => {
207
213
  Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
208
- Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
209
- }, [isFocused, focus, glow]);
214
+ // Reduce Motion: halo and indicator bar only fade (their scales are pinned below).
215
+ if (reduce) Animated.timing(glow, { toValue: isFocused ? 1 : 0, duration: 150, useNativeDriver: true }).start();
216
+ else Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
217
+ }, [isFocused, focus, glow, reduce]);
210
218
 
211
219
  useEffect(() => {
212
- Animated.spring(clear, { toValue: clearable && text && editable ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
213
- }, [clearable, text, editable, clear]);
220
+ const to = clearable && text && editable ? 1 : 0;
221
+ if (reduce) Animated.timing(clear, { toValue: to, duration: 150, useNativeDriver: true }).start();
222
+ else Animated.spring(clear, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
223
+ }, [clearable, text, editable, clear, reduce]);
214
224
 
215
225
  useEffect(() => {
216
- Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
217
- }, [ok, check]);
226
+ if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
227
+ else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
228
+ }, [ok, check, reduce]);
218
229
 
219
230
  useEffect(() => {
220
- if (!error) return;
231
+ // Reduce Motion: no shake; the red border and message still flag the error.
232
+ if (!error || reduce) return;
221
233
  shake.setValue(0);
222
234
  Animated.sequence(
223
235
  [10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
224
236
  ).start();
225
- }, [error, shake]);
237
+ }, [error, shake, reduce]);
226
238
 
227
239
  const setText = (next: string) => {
228
240
  if (value === undefined) setInner(next);
@@ -310,23 +322,24 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
310
322
  borderRadius: corner + HALO,
311
323
  backgroundColor: active,
312
324
  opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
313
- transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
325
+ transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.97, 1] }) }],
314
326
  },
315
327
  ]}
316
328
  />
317
329
  )}
318
330
  <Pressable onPress={() => inputRef.current?.focus()} disabled={!editable} accessible={false}>
319
- <Animated.View style={[styles.field, boxStyle, { height: HEIGHT }, userField, { borderColor }]}>
331
+ <Animated.View style={[styles.field, boxStyle, { minHeight: HEIGHT }, userField, { borderColor }]}>
320
332
  {left ? <View style={[styles.left, { left: underline ? 2 : S.padX - 2 }, underline && { top: contentTop }]}>{left}</View> : null}
321
333
  <View style={[styles.row, { paddingLeft: leftPad, paddingRight: rightPad, paddingTop: contentTop }]}>
322
334
  {prefix ? (
323
- <Animated.Text style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
335
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
324
336
  {prefix}
325
337
  </Animated.Text>
326
338
  ) : null}
327
339
  <TextInput
328
340
  ref={inputRef}
329
341
  accessibilityLabel={label}
342
+ maxFontSizeMultiplier={FONT_CAP}
330
343
  {...props}
331
344
  maxLength={maxLength}
332
345
  editable={editable}
@@ -347,7 +360,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
347
360
  style={[styles.input, ff, { color: colors.foreground, fontSize: S.font }, inputStyle, NO_WEB_OUTLINE]}
348
361
  />
349
362
  {suffix ? (
350
- <Animated.Text style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
363
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
351
364
  {suffix}
352
365
  </Animated.Text>
353
366
  ) : null}
@@ -358,6 +371,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
358
371
  style={[styles.labelWrap, { left: leftPad, right: right ? adorn : S.padX - 4, top: contentTop, height: HEIGHT - contentTop }, labelStyle]}
359
372
  >
360
373
  <Animated.Text
374
+ maxFontSizeMultiplier={FONT_CAP}
361
375
  numberOfLines={1}
362
376
  style={[
363
377
  styles.label,
@@ -380,7 +394,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
380
394
  ) : null}
381
395
  {clearable ? (
382
396
  <Animated.View
383
- style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: clear }] }]}
397
+ style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: reduce ? 1 : clear }] }]}
384
398
  pointerEvents={text && editable ? 'box-none' : 'none'}
385
399
  >
386
400
  <Pressable
@@ -408,8 +422,8 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
408
422
  top: contentTop,
409
423
  opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
410
424
  transform: [
411
- { scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
412
- { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) },
425
+ { scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) },
426
+ { rotate: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? '0deg' : '-30deg', '0deg'] }) },
413
427
  ],
414
428
  },
415
429
  ]}
@@ -423,10 +437,14 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
423
437
  {outlined ? null : (
424
438
  <>
425
439
  <View pointerEvents="none" style={[styles.baseLine, { height: underline ? 1.5 : 1, backgroundColor: rest }]} />
426
- {/* Grows out from the centre on focus. */}
440
+ {/* Grows out from the centre on focus (fades in with Reduce Motion). */}
427
441
  <Animated.View
428
442
  pointerEvents="none"
429
- style={[styles.bar, { backgroundColor: active, transform: [{ scaleX: glowClamped }] }]}
443
+ style={[
444
+ styles.bar,
445
+ { backgroundColor: active },
446
+ reduce ? { opacity: glowClamped } : { transform: [{ scaleX: glowClamped }] },
447
+ ]}
430
448
  />
431
449
  </>
432
450
  )}
@@ -436,10 +454,11 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
436
454
  {message || showCounter ? (
437
455
  <View style={[styles.footer, underline && styles.footerFlush]}>
438
456
  <View style={styles.messageSlot}>
439
- {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} /> : null}
457
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} reduce={reduce} /> : null}
440
458
  </View>
441
459
  {showCounter ? (
442
460
  <Text
461
+ maxFontSizeMultiplier={FONT_CAP}
443
462
  style={[
444
463
  styles.count,
445
464
  ff,
@@ -459,13 +478,27 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
459
478
 
460
479
  type MessageTone = 'error' | 'success' | 'helper';
461
480
 
462
- /** Helper / error / success line. Error and success slide up and fade in. */
463
- function FieldMessage({ text, tone, color, style }: { text: string; tone: MessageTone; color: string; style?: StyleProp<TextStyle> }) {
481
+ /** Helper / error / success line. Error and success slide up and fade in (only fade with Reduce Motion). */
482
+ function FieldMessage({
483
+ text,
484
+ tone,
485
+ color,
486
+ style,
487
+ reduce,
488
+ }: {
489
+ text: string;
490
+ tone: MessageTone;
491
+ color: string;
492
+ style?: StyleProp<TextStyle>;
493
+ reduce: boolean;
494
+ }) {
464
495
  const { font } = useKit();
465
496
  const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
466
497
  useEffect(() => {
467
- if (tone !== 'helper') Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
468
- }, [tone, enter]);
498
+ if (tone === 'helper') return;
499
+ if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
500
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
501
+ }, [tone, enter, reduce]);
469
502
 
470
503
  return (
471
504
  <Animated.View
@@ -473,7 +506,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
473
506
  styles.message,
474
507
  {
475
508
  opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
476
- transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
509
+ transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
477
510
  },
478
511
  ]}
479
512
  >
@@ -510,11 +543,27 @@ function flattenView(style: StyleProp<ViewStyle>): ViewStyle {
510
543
  return style as ViewStyle;
511
544
  }
512
545
 
546
+ function useReduceMotion() {
547
+ const [reduce, setReduce] = useState(false);
548
+ useEffect(() => {
549
+ let alive = true;
550
+ AccessibilityInfo.isReduceMotionEnabled()
551
+ .then((r) => alive && setReduce(r))
552
+ .catch(() => {});
553
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
554
+ return () => {
555
+ alive = false;
556
+ sub.remove();
557
+ };
558
+ }, []);
559
+ return reduce;
560
+ }
561
+
513
562
  const styles = StyleSheet.create({
514
563
  disabled: { opacity: 0.5 },
515
564
  halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
516
565
  field: { justifyContent: 'center' },
517
- row: { flexDirection: 'row', alignItems: 'center', height: '100%' },
566
+ row: { flexDirection: 'row', alignItems: 'center', flexGrow: 1 },
518
567
  input: { flex: 1, alignSelf: 'stretch', paddingVertical: 0, paddingHorizontal: 0 },
519
568
  affix: { fontWeight: '400' },
520
569
  prefix: { marginRight: 4 },
@@ -1,5 +1,5 @@
1
1
  import React, { forwardRef, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
- import { Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
3
3
  import Svg, { Circle, Path } from 'react-native-svg';
4
4
 
5
5
  import { FloatingInput, type FloatingInputProps } from '@/components/ui/floating-input';
@@ -34,6 +34,10 @@ export interface PasswordInputProps extends Omit<FloatingInputProps, 'secureText
34
34
  mismatchMessage?: string;
35
35
  }
36
36
 
37
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
38
+ // content text scales all the way.
39
+ const FONT_CAP = 1.35;
40
+
37
41
  const LEVEL_LABELS = ['Too short', 'Weak', 'Okay', 'Strong', 'Very strong'];
38
42
  const ICON = { sm: 18, md: 22, lg: 24 };
39
43
 
@@ -70,6 +74,7 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
70
74
  ref
71
75
  ) {
72
76
  const { colors } = useKit();
77
+ const reduce = useReduceMotion();
73
78
  const [hidden, setHidden] = useState(true);
74
79
  const [inner, setInner] = useState(defaultValue ?? '');
75
80
  const text = value ?? inner;
@@ -84,17 +89,21 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
84
89
 
85
90
  const eye = useRef(new Animated.Value(hidden ? 0 : 1)).current; // native: 1 = visible
86
91
  useEffect(() => {
87
- Animated.spring(eye, { toValue: hidden ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
88
- }, [hidden, eye]);
92
+ if (reduce) Animated.timing(eye, { toValue: hidden ? 0 : 1, duration: 150, useNativeDriver: true }).start();
93
+ else Animated.spring(eye, { toValue: hidden ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
94
+ }, [hidden, eye, reduce]);
89
95
 
90
96
  const iconColor = iconColorProp ?? colors.mutedForeground;
91
97
  const iconSize = ICON[size];
92
98
  const fade = (on: 0 | 1) => ({
93
99
  opacity: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0, 1] : [1, 0], extrapolate: 'clamp' }),
94
- transform: [
95
- { scale: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0.6, 1] : [1, 0.6] }) },
96
- { rotate: eye.interpolate({ inputRange: [0, 1], outputRange: on ? ['-45deg', '0deg'] : ['0deg', '45deg'] }) },
97
- ],
100
+ // Reduce Motion: a plain cross-fade, no spin or scale.
101
+ transform: reduce
102
+ ? []
103
+ : [
104
+ { scale: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0.6, 1] : [1, 0.6] }) },
105
+ { rotate: eye.interpolate({ inputRange: [0, 1], outputRange: on ? ['-45deg', '0deg'] : ['0deg', '45deg'] }) },
106
+ ],
98
107
  });
99
108
 
100
109
  return (
@@ -137,8 +146,8 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
137
146
  </Pressable>
138
147
  }
139
148
  />
140
- {strength && !confirming && text.length > 0 ? <Meter level={level} /> : null}
141
- {rules && !confirming ? <Checklist rules={rules} value={text} /> : null}
149
+ {strength && !confirming && text.length > 0 ? <Meter level={level} reduce={reduce} /> : null}
150
+ {rules && !confirming ? <Checklist rules={rules} value={text} reduce={reduce} /> : null}
142
151
  </View>
143
152
  );
144
153
  });
@@ -151,7 +160,7 @@ const EYE_OFF =
151
160
  'M2 2l20 20';
152
161
 
153
162
  /** Four segments that spring-fill left to right; the colour slides with the score. */
154
- function Meter({ level }: { level: number }) {
163
+ function Meter({ level, reduce }: { level: number; reduce: boolean }) {
155
164
  const { colors, dark, font } = useKit();
156
165
  const palette = [colors.mutedForeground, colors.danger, colors.warning, colors.success, colors.success];
157
166
  const fills = useRef([0, 1, 2, 3].map(() => new Animated.Value(0))).current; // native: scaleX
@@ -159,16 +168,24 @@ function Meter({ level }: { level: number }) {
159
168
  const enter = useRef(new Animated.Value(0)).current; // native
160
169
 
161
170
  useEffect(() => {
162
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
171
+ if (reduce) enter.setValue(1);
172
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
174
  }, [enter]);
164
175
 
165
176
  useEffect(() => {
177
+ if (reduce) {
178
+ // Segments appear filled at once; the colour still fades.
179
+ fills.forEach((a, i) => a.setValue(i < level ? 1 : 0));
180
+ Animated.timing(tone, { toValue: level, duration: 150, useNativeDriver: false }).start();
181
+ return;
182
+ }
166
183
  Animated.stagger(
167
184
  50,
168
185
  fills.map((a, i) => Animated.spring(a, { toValue: i < level ? 1 : 0, useNativeDriver: true, ...motion.snappy }))
169
186
  ).start();
170
187
  Animated.timing(tone, { toValue: level, duration: 220, useNativeDriver: false }).start();
171
- }, [level, fills, tone]);
188
+ }, [level, fills, tone, reduce]);
172
189
 
173
190
  const color = tone.interpolate({ inputRange: [0, 1, 2, 3, 4], outputRange: palette });
174
191
  const track = dark ? colors.muted : colors.border;
@@ -199,27 +216,28 @@ function Meter({ level }: { level: number }) {
199
216
  </View>
200
217
  ))}
201
218
  </View>
202
- <Text style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
219
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
203
220
  </Animated.View>
204
221
  );
205
222
  }
206
223
 
207
- function Checklist({ rules, value }: { rules: PasswordRequirement[]; value: string }) {
224
+ function Checklist({ rules, value, reduce }: { rules: PasswordRequirement[]; value: string; reduce: boolean }) {
208
225
  return (
209
226
  <View style={styles.checklist} accessibilityRole="list">
210
227
  {rules.map((r) => (
211
- <Requirement key={r.label} label={r.label} met={r.test(value)} />
228
+ <Requirement key={r.label} label={r.label} met={r.test(value)} reduce={reduce} />
212
229
  ))}
213
230
  </View>
214
231
  );
215
232
  }
216
233
 
217
- function Requirement({ label, met }: { label: string; met: boolean }) {
234
+ function Requirement({ label, met, reduce }: { label: string; met: boolean; reduce: boolean }) {
218
235
  const { colors, dark, font } = useKit();
219
236
  const tick = useRef(new Animated.Value(met ? 1 : 0)).current; // native
220
237
  useEffect(() => {
221
- Animated.spring(tick, { toValue: met ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
222
- }, [met, tick]);
238
+ if (reduce) Animated.timing(tick, { toValue: met ? 1 : 0, duration: 150, useNativeDriver: true }).start();
239
+ else Animated.spring(tick, { toValue: met ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
240
+ }, [met, tick, reduce]);
223
241
 
224
242
  return (
225
243
  <View
@@ -237,7 +255,7 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
237
255
  StyleSheet.absoluteFill,
238
256
  {
239
257
  opacity: tick.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
240
- transform: [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
258
+ transform: reduce ? [] : [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
241
259
  },
242
260
  ]}
243
261
  >
@@ -258,6 +276,22 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
258
276
  );
259
277
  }
260
278
 
279
+ function useReduceMotion() {
280
+ const [reduce, setReduce] = useState(false);
281
+ useEffect(() => {
282
+ let alive = true;
283
+ AccessibilityInfo.isReduceMotionEnabled()
284
+ .then((r) => alive && setReduce(r))
285
+ .catch(() => {});
286
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
287
+ return () => {
288
+ alive = false;
289
+ sub.remove();
290
+ };
291
+ }, []);
292
+ return reduce;
293
+ }
294
+
261
295
  const styles = StyleSheet.create({
262
296
  meter: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 10, marginHorizontal: 4 },
263
297
  bars: { flex: 1, flexDirection: 'row', gap: 6 },
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Animated, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { motion } from '@/lib/kit';
5
5
 
@@ -48,12 +48,15 @@ const SQUEEZE = {
48
48
  * The flip as an animated 0 → 1 value, springing like FlipCard's own, plus `shadowScaleX` — pass
49
49
  * `progress` to the FlipCard and `shadowScaleX` to a TiltCard wrapped round it, so the tilt
50
50
  * shadow narrows as the card turns edge-on instead of showing as a dark box behind it.
51
+ * With Reduce Motion on, the card turns over at once (no 3D turn).
51
52
  */
52
53
  export function useFlipProgress(flipped: boolean) {
54
+ const reduce = useReduceMotion();
53
55
  const progress = useRef(new Animated.Value(flipped ? 1 : 0)).current;
54
56
  useEffect(() => {
55
- Animated.spring(progress, { toValue: flipped ? 1 : 0, ...motion.gentle, useNativeDriver: true }).start();
56
- }, [flipped, progress]);
57
+ if (reduce) progress.setValue(flipped ? 1 : 0);
58
+ else Animated.spring(progress, { toValue: flipped ? 1 : 0, ...motion.gentle, useNativeDriver: true }).start();
59
+ }, [flipped, progress, reduce]);
57
60
  const shadowScaleX = useMemo(() => progress.interpolate(SQUEEZE), [progress]);
58
61
  return { progress, shadowScaleX };
59
62
  }
@@ -185,6 +188,22 @@ export function FlipCard({
185
188
  );
186
189
  }
187
190
 
191
+ function useReduceMotion() {
192
+ const [reduce, setReduce] = useState(false);
193
+ useEffect(() => {
194
+ let alive = true;
195
+ AccessibilityInfo.isReduceMotionEnabled()
196
+ .then((r) => alive && setReduce(r))
197
+ .catch(() => {});
198
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
199
+ return () => {
200
+ alive = false;
201
+ sub.remove();
202
+ };
203
+ }, []);
204
+ return reduce;
205
+ }
206
+
188
207
  const styles = StyleSheet.create({
189
208
  face: { overflow: 'hidden', backfaceVisibility: 'hidden' },
190
209
  back: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
@@ -71,6 +71,10 @@ export interface PaymentCardLabels {
71
71
  accessibility?: string;
72
72
  }
73
73
 
74
+ // Everything is printed on a fixed-shape card, so all its text is control text: it stops growing at iOS's largest
75
+ // standard text size (xxxL ≈ 1.35×) so the card keeps its shape.
76
+ const FONT_CAP = 1.35;
77
+
74
78
  /**
75
79
  * "4821 7590 1234 0317" → groups of 4 (Amex 4-6-5, 14-digit Diners 4-6-4, 19-digit cards get a
76
80
  * fifth group). Masking hides every digit but the last four, whatever the length. Keeps • placeholders.
@@ -111,19 +115,19 @@ function BrandMark({ brand, ink }: { brand: PaymentCardBrand; ink: string }) {
111
115
  );
112
116
  }
113
117
  if (brand === 'visa') {
114
- return <Text style={[styles.visa, { color: ink }]}>VISA</Text>;
118
+ return <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.visa, { color: ink }]}>VISA</Text>;
115
119
  }
116
120
  if (brand === 'amex') {
117
121
  return (
118
122
  <View style={[styles.amex, { borderColor: ink }]}>
119
- <Text style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>AMERICAN</Text>
120
- <Text style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>EXPRESS</Text>
123
+ <Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>AMERICAN</Text>
124
+ <Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 9, fontWeight: '800', letterSpacing: 0.6, lineHeight: 10 }}>EXPRESS</Text>
121
125
  </View>
122
126
  );
123
127
  }
124
128
  return (
125
129
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
126
- <Text style={{ color: ink, fontSize: 18, fontWeight: '800', fontStyle: 'italic', letterSpacing: -0.3 }}>RuPay</Text>
130
+ <Text maxFontSizeMultiplier={FONT_CAP} style={{ color: ink, fontSize: 18, fontWeight: '800', fontStyle: 'italic', letterSpacing: -0.3 }}>RuPay</Text>
127
131
  <Svg width={14} height={16} viewBox="0 0 14 16">
128
132
  <Path d="M2 1 L10 8 L2 15 Z" fill="#F47920" />
129
133
  <Path d="M6 1 L14 8 L6 15 Z" fill="#0B8E36" />
@@ -278,28 +282,28 @@ export function PaymentCard({
278
282
  {tone === 'light' ? <Shade strength={image ? 0.38 : 0.2} /> : null}
279
283
  <View style={styles.inner}>
280
284
  <View style={styles.row}>
281
- <Text style={[{ color: ink, fontSize: 16, fontWeight: '700', letterSpacing: -0.2 }, ff, emboss]}>{bank ?? ''}</Text>
282
- {label ? <Text style={[{ color: soft, fontSize: 11, fontWeight: '600', letterSpacing: 1 }, ff, emboss]}>{label.toUpperCase()}</Text> : null}
285
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ flexShrink: 1, color: ink, fontSize: 16, fontWeight: '700', letterSpacing: -0.2 }, ff, emboss]}>{bank ?? ''}</Text>
286
+ {label ? <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: soft, fontSize: 11, fontWeight: '600', letterSpacing: 1 }, ff, emboss]}>{label.toUpperCase()}</Text> : null}
283
287
  </View>
284
288
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
285
289
  <Chip finish={chip} />
286
290
  <Contactless ink={ink} />
287
291
  </View>
288
292
  <View style={{ gap: 10 }}>
289
- <Text style={[{ color: ink, fontSize: 19, fontWeight: '500', letterSpacing: 2, fontVariant: ['tabular-nums'] }, ff, emboss]}>
293
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 19, fontWeight: '500', letterSpacing: 2, fontVariant: ['tabular-nums'] }, ff, emboss]}>
290
294
  {formatNumber(number, brand, masked)}
291
295
  </Text>
292
296
  <View style={[styles.row, { alignItems: 'flex-end' }]}>
293
297
  <View style={{ flexDirection: 'row', gap: 20, flexShrink: 1 }}>
294
298
  <View style={{ flexShrink: 1 }}>
295
- <Text style={[styles.tiny, ff, { color: soft }]}>{text.cardholder}</Text>
296
- <Text style={[{ color: ink, fontSize: 13, fontWeight: '600', letterSpacing: 0.8 }, ff, emboss]} numberOfLines={1}>
299
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.tiny, ff, { color: soft }]}>{text.cardholder}</Text>
300
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 13, fontWeight: '600', letterSpacing: 0.8 }, ff, emboss]} numberOfLines={1}>
297
301
  {name.toUpperCase()}
298
302
  </Text>
299
303
  </View>
300
304
  <View>
301
- <Text style={[styles.tiny, ff, { color: soft }]}>{text.validThru}</Text>
302
- <Text style={[{ color: ink, fontSize: 13, fontWeight: '600', fontVariant: ['tabular-nums'] }, ff, emboss]}>{expiry}</Text>
305
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.tiny, ff, { color: soft }]}>{text.validThru}</Text>
306
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: 13, fontWeight: '600', fontVariant: ['tabular-nums'] }, ff, emboss]}>{expiry}</Text>
303
307
  </View>
304
308
  </View>
305
309
  <BrandMark brand={brand} ink={ink} />
@@ -309,7 +313,7 @@ export function PaymentCard({
309
313
  {frozen ? (
310
314
  <View style={[StyleSheet.absoluteFill, styles.center, { backgroundColor: 'rgba(214,232,255,0.28)' }]}>
311
315
  <View style={styles.frozenPill}>
312
- <Text style={[{ color: '#ffffff', fontSize: 13, fontWeight: '700', letterSpacing: 0.4 }, ff]}>❄︎ {text.frozen}</Text>
316
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: '#ffffff', fontSize: 13, fontWeight: '700', letterSpacing: 0.4 }, ff]}>❄︎ {text.frozen}</Text>
313
317
  </View>
314
318
  </View>
315
319
  ) : null}
@@ -330,15 +334,15 @@ export function PaymentCard({
330
334
  </Svg>
331
335
  </View>
332
336
  <View style={styles.cvv}>
333
- <Text style={[{ color: '#111', fontSize: 15, fontWeight: '700', fontStyle: 'italic', letterSpacing: 1.5, fontVariant: ['tabular-nums'] }, ff]}>{cvv ?? '•••'}</Text>
337
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: '#111', fontSize: 15, fontWeight: '700', fontStyle: 'italic', letterSpacing: 1.5, fontVariant: ['tabular-nums'] }, ff]}>{cvv ?? '•••'}</Text>
334
338
  </View>
335
339
  </View>
336
340
  <View style={styles.backFoot}>
337
341
  <View style={{ flex: 1, gap: 3 }}>
338
- <Text style={[{ color: 'rgba(255,255,255,0.78)', fontSize: 9, lineHeight: 12 }, ff]}>
342
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: 'rgba(255,255,255,0.78)', fontSize: 9, lineHeight: 12 }, ff]}>
339
343
  {fill(text.legal)}
340
344
  </Text>
341
- <Text style={[{ color: 'rgba(255,255,255,0.9)', fontSize: 10, fontWeight: '600' }, ff]}>{fill(text.back)}</Text>
345
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: 'rgba(255,255,255,0.9)', fontSize: 10, fontWeight: '600' }, ff]}>{fill(text.back)}</Text>
342
346
  </View>
343
347
  {/* Security hologram. */}
344
348
  <Svg width={40} height={30} viewBox="0 0 40 30">