@expokit/cli 0.4.0 → 0.4.2

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 (102) hide show
  1. package/README.md +17 -0
  2. package/bin/lib.mjs +1 -1
  3. package/bin/liquidglass.mjs +281 -0
  4. package/package.json +17 -3
  5. package/registry/_docs/accordion.json +1 -1
  6. package/registry/_docs/avatar.json +6 -6
  7. package/registry/_docs/badge.json +3 -3
  8. package/registry/_docs/bottom-nav.json +6 -6
  9. package/registry/_docs/bottom-sheet.json +3 -3
  10. package/registry/_docs/bullet.json +6 -0
  11. package/registry/_docs/button-3d.json +2 -2
  12. package/registry/_docs/button.json +2 -2
  13. package/registry/_docs/calendar.json +5 -5
  14. package/registry/_docs/card.json +1 -1
  15. package/registry/_docs/chip-group.json +1 -1
  16. package/registry/_docs/confetti.json +3 -3
  17. package/registry/_docs/date-picker.json +4 -4
  18. package/registry/_docs/drawer.json +4 -4
  19. package/registry/_docs/flip-card.json +4 -4
  20. package/registry/_docs/floating-input.json +1 -1
  21. package/registry/_docs/grain.json +2 -2
  22. package/registry/_docs/isometric-bar.json +6 -0
  23. package/registry/_docs/liquidglass-tab.json +2 -1
  24. package/registry/_docs/otp-input.json +1 -1
  25. package/registry/_docs/payment-card.json +3 -3
  26. package/registry/_docs/pixel-bar.json +6 -0
  27. package/registry/_docs/polar-area.json +6 -0
  28. package/registry/_docs/progress-steps.json +4 -4
  29. package/registry/_docs/progress.json +2 -2
  30. package/registry/_docs/radio-group.json +1 -1
  31. package/registry/_docs/search-bar.json +3 -3
  32. package/registry/_docs/segmented-control.json +1 -1
  33. package/registry/_docs/select.json +6 -6
  34. package/registry/_docs/skeleton.json +1 -1
  35. package/registry/_docs/slider.json +6 -6
  36. package/registry/_docs/spinner.json +2 -2
  37. package/registry/_docs/stateful-button.json +1 -1
  38. package/registry/_docs/stepper.json +3 -3
  39. package/registry/_docs/tabs.json +2 -2
  40. package/registry/_docs/text-animation.json +4 -4
  41. package/registry/_docs/textarea.json +1 -1
  42. package/registry/_docs/tilt-card.json +3 -3
  43. package/registry/_docs/time-picker.json +6 -6
  44. package/registry/_docs/timeline-chart.json +6 -0
  45. package/registry/_docs/timeline.json +4 -4
  46. package/registry/_docs/treemap.json +6 -0
  47. package/registry/_docs/waterfall.json +6 -0
  48. package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
  49. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
  50. package/registry/alert/files/src/components/ui/alert.tsx +5 -2
  51. package/registry/alert/files/src/components/ui/button.tsx +32 -4
  52. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  53. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
  54. package/registry/button/files/src/components/ui/button.tsx +32 -4
  55. package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
  56. package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
  57. package/registry/card/files/src/components/ui/card.tsx +25 -5
  58. package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
  59. package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
  60. package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
  61. package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
  62. package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
  63. package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
  64. package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
  65. package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
  66. package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
  67. package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
  68. package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
  69. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +3 -94
  70. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +2 -1
  71. package/registry/liquidglass-tab/files/src/app/_layout.tsx +2 -2
  72. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -1
  73. package/registry/liquidglass-tab/files/src/components/LiquidGlassTabs.tsx +110 -0
  74. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -6
  75. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +12 -4
  76. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +6 -3
  77. package/registry/liquidglass-tab/files/src/lib/tabs.ts +4 -2
  78. package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
  79. package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
  80. package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
  81. package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
  82. package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
  83. package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
  84. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
  85. package/registry/progress/files/src/components/ui/progress.tsx +9 -2
  86. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
  87. package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
  88. package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
  89. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
  90. package/registry/select/files/src/components/ui/select.tsx +158 -83
  91. package/registry/slider/files/src/components/ui/slider.tsx +53 -25
  92. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  93. package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
  94. package/registry/switch/files/src/components/ui/switch.tsx +56 -19
  95. package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
  96. package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
  97. package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
  98. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
  99. package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
  100. package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
  101. package/registry/toast/files/src/components/ui/button.tsx +32 -4
  102. package/registry/toast/files/src/components/ui/toast.tsx +15 -6
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Modal,
@@ -132,6 +133,9 @@ type FlatProps = DatePickerBaseProps & {
132
133
  presets?: boolean | DatePickerPreset<Picked>[];
133
134
  };
134
135
 
136
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
137
+ // content text scales all the way.
138
+ const FONT_CAP = 1.35;
135
139
  const SIZES: Record<DatePickerSize, { h: number; font: number; pad: number; icon: number }> = {
136
140
  sm: { h: 46, font: 15, pad: 12, icon: 17 },
137
141
  md: { h: 56, font: 16, pad: 16, icon: 19 },
@@ -241,6 +245,10 @@ export function DatePicker(props: DatePickerProps) {
241
245
  const restIcon = iconColor ?? colors.mutedForeground;
242
246
  const { width: winW, height: winH } = useWindowDimensions();
243
247
  const ranged = mode === 'range';
248
+ const reduce = useReduceMotion();
249
+ // The pan responder is built once; it reads Reduce Motion through this ref.
250
+ const live = useRef({ reduce });
251
+ live.current = { reduce };
244
252
 
245
253
  const [inner, setInner] = useState<Picked>(defaultValue);
246
254
  const current = value !== undefined ? value : inner;
@@ -269,26 +277,36 @@ export function DatePicker(props: DatePickerProps) {
269
277
  const wide = presentation === 'dialog' || (presentation === 'auto' && winW >= 640);
270
278
 
271
279
  useEffect(() => {
280
+ if (reduce) {
281
+ float.setValue(floating ? 1 : 0);
282
+ return;
283
+ }
272
284
  Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
273
- }, [floating, float]);
285
+ }, [floating, float, reduce]);
274
286
  useEffect(() => {
275
287
  Animated.timing(focus, { toValue: open ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
276
- Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
277
- }, [open, focus, halo]);
288
+ // Reduce Motion: the halo fades in place (its scale interpolation is flat).
289
+ if (reduce) Animated.timing(halo, { toValue: open ? 1 : 0, duration: 150, useNativeDriver: true }).start();
290
+ else Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
291
+ }, [open, focus, halo, reduce]);
278
292
  useEffect(() => {
279
- Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
280
- }, [ok, check]);
293
+ if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
294
+ else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
295
+ }, [ok, check, reduce]);
281
296
  useEffect(() => {
282
- if (!error) return;
297
+ // Reduce Motion: no shake — the red border and message carry the error.
298
+ if (!error || live.current.reduce) return;
283
299
  shake.setValue(0);
284
300
  Animated.sequence([10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))).start();
285
301
  }, [error, shake]);
286
302
  useEffect(() => {
287
303
  if (open) {
288
304
  drag.setValue(0);
289
- Animated.spring(show, { toValue: 1, useNativeDriver: true, ...(asSheet ? motion.gentle : motion.snappy) }).start();
305
+ // Reduce Motion: the panel fades in place (its translate / scale interpolations are flat).
306
+ if (reduce) Animated.timing(show, { toValue: 1, duration: 150, useNativeDriver: true }).start();
307
+ else Animated.spring(show, { toValue: 1, useNativeDriver: true, ...(asSheet ? motion.gentle : motion.snappy) }).start();
290
308
  } else if (mounted) {
291
- Animated.timing(show, { toValue: 0, duration: motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
309
+ Animated.timing(show, { toValue: 0, duration: reduce ? 150 : motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
292
310
  ({ finished }) => finished && setMounted(false)
293
311
  );
294
312
  }
@@ -395,21 +413,23 @@ export function DatePicker(props: DatePickerProps) {
395
413
  const same = (a: Picked, b: Picked) =>
396
414
  isRange(a) && isRange(b) ? isSameDay(a.start, b.start) && isSameDay(a.end, b.end) : !isRange(a) && !isRange(b) && isSameDay(a, b);
397
415
 
398
- const pan = useMemo(
399
- () =>
400
- PanResponder.create({
401
- onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
402
- onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
403
- onPanResponderRelease: (_, g) => {
404
- if (g.dy > 110 || g.vy > 1.1) {
405
- setOpen(false);
406
- onOpenChange?.(false);
407
- } else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
408
- },
409
- onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
410
- }),
411
- [drag, onOpenChange]
412
- );
416
+ const pan = useMemo(() => {
417
+ const settle = () =>
418
+ live.current.reduce
419
+ ? Animated.timing(drag, { toValue: 0, duration: 150, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true }).start()
420
+ : Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
421
+ return PanResponder.create({
422
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
423
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
424
+ onPanResponderRelease: (_, g) => {
425
+ if (g.dy > 110 || g.vy > 1.1) {
426
+ setOpen(false);
427
+ onOpenChange?.(false);
428
+ } else settle();
429
+ },
430
+ onPanResponderTerminate: settle,
431
+ });
432
+ }, [drag, onOpenChange]);
413
433
 
414
434
  const active = error ? colors.danger : tint;
415
435
  const rest = error ? colors.danger : ok ? colors.success : ((userBorder as string | undefined) ?? colors.input);
@@ -469,7 +489,7 @@ export function DatePicker(props: DatePickerProps) {
469
489
  },
470
490
  ]}
471
491
  >
472
- <Text style={[styles.presetText, ff, { color: on ? tint : colors.foreground }]}>{p.label}</Text>
492
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.presetText, ff, { color: on ? tint : colors.foreground }]}>{p.label}</Text>
473
493
  </Pressable>
474
494
  );
475
495
  })}
@@ -498,14 +518,16 @@ export function DatePicker(props: DatePickerProps) {
498
518
  accessibilityRole="button"
499
519
  style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: draft ? (pressed ? 0.6 : 1) : 0.4 }]}
500
520
  >
501
- <Text style={[styles.footText, ff, { color: colors.foreground }]}>Clear</Text>
521
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.footText, ff, { color: colors.foreground }]}>
522
+ Clear
523
+ </Text>
502
524
  </Pressable>
503
525
  <Pressable
504
526
  onPress={done}
505
527
  accessibilityRole="button"
506
528
  style={({ pressed }) => [styles.footBtn, styles.footMain, { backgroundColor: tint, borderRadius: radius.md, opacity: pressed ? 0.85 : 1 }]}
507
529
  >
508
- <Text style={[styles.footText, ff, { color: readableOn(tint) }]}>
530
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.footText, ff, { color: readableOn(tint) }]}>
509
531
  {draftDays > 1 ? `Done · ${draftDays} days` : 'Done'}
510
532
  </Text>
511
533
  </Pressable>
@@ -558,13 +580,14 @@ export function DatePicker(props: DatePickerProps) {
558
580
  borderRadius: fieldRadius + 4,
559
581
  borderColor: alpha(active, 0.2),
560
582
  opacity: halo,
561
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
583
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.97, 1] }) }],
562
584
  },
563
585
  ]}
564
586
  />
565
- <Animated.View style={[styles.field, { height: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
587
+ <Animated.View style={[styles.field, { minHeight: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
566
588
  <Text
567
589
  numberOfLines={1}
590
+ maxFontSizeMultiplier={FONT_CAP}
568
591
  style={[
569
592
  styles.value,
570
593
  ff,
@@ -592,7 +615,7 @@ export function DatePicker(props: DatePickerProps) {
592
615
  ]}
593
616
  >
594
617
  {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
595
- <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
618
+ <Animated.Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
596
619
  {label}
597
620
  </Animated.Text>
598
621
  </Animated.View>
@@ -603,7 +626,7 @@ export function DatePicker(props: DatePickerProps) {
603
626
  style={{
604
627
  display: ok ? 'flex' : 'none',
605
628
  opacity: check,
606
- transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }],
629
+ transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) }],
607
630
  }}
608
631
  >
609
632
  <Icon name="check" size={sz.icon} color={colors.success} />
@@ -623,7 +646,7 @@ export function DatePicker(props: DatePickerProps) {
623
646
  </View>
624
647
  </Animated.View>
625
648
  </Pressable>
626
- {message ? <FieldMessage key={tone + message} text={message} tone={tone} style={messageStyle} /> : null}
649
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} reduce={reduce} style={messageStyle} /> : null}
627
650
 
628
651
  <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
629
652
  <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: asSheet ? colors.overlay : 'transparent', opacity: show }]}>
@@ -644,11 +667,15 @@ export function DatePicker(props: DatePickerProps) {
644
667
  ? {
645
668
  opacity: show,
646
669
  transform: [
647
- { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
648
- { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
670
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.94, 1] }) },
671
+ { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 16, 0] }), drag) },
649
672
  ],
650
673
  }
651
- : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.85, 0] }), drag) }] },
674
+ : {
675
+ // Reduce Motion: the sheet fades in place instead of sliding up.
676
+ opacity: reduce ? show : 1,
677
+ transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : winH * 0.85, 0] }), drag) }],
678
+ },
652
679
  ]}
653
680
  >
654
681
  <View {...pan.panHandlers} style={styles.sheetHeader}>
@@ -681,8 +708,8 @@ export function DatePicker(props: DatePickerProps) {
681
708
  {
682
709
  opacity: show,
683
710
  transform: [
684
- { translateY: show.interpolate({ inputRange: [0, 1], outputRange: [below ? -8 : 8, 0] }) },
685
- { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },
711
+ { translateY: show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : below ? -8 : 8, 0] }) },
712
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.96, 1] }) },
686
713
  ],
687
714
  },
688
715
  below ? { top: anchor.y + anchor.h + 6 } : { bottom: Math.max(12, winH - anchor.y + 6) },
@@ -697,17 +724,28 @@ export function DatePicker(props: DatePickerProps) {
697
724
  }
698
725
 
699
726
  /** Helper / error / success line that slides in; error and success carry an icon. */
700
- function FieldMessage({ text, tone, style }: { text: string; tone: 'error' | 'success' | 'helper'; style?: StyleProp<TextStyle> }) {
727
+ function FieldMessage({
728
+ text,
729
+ tone,
730
+ reduce,
731
+ style,
732
+ }: {
733
+ text: string;
734
+ tone: 'error' | 'success' | 'helper';
735
+ reduce: boolean;
736
+ style?: StyleProp<TextStyle>;
737
+ }) {
701
738
  const { colors, font } = useKit();
702
739
  const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
703
740
  useEffect(() => {
704
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
705
- }, [enter]);
741
+ if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
742
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
743
+ }, [enter, reduce]);
706
744
  const color = tone === 'error' ? colors.danger : tone === 'success' ? colors.success : colors.mutedForeground;
707
745
  return (
708
746
  <Animated.View
709
747
  accessibilityLiveRegion="polite"
710
- style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}
748
+ style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }] }]}
711
749
  >
712
750
  {tone !== 'helper' ? <Icon name={tone === 'error' ? 'alert' : 'checkCircle'} size={14} color={color} /> : null}
713
751
  <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, tone !== 'helper' && { color }]}>{text}</Text>
@@ -747,6 +785,22 @@ function Icon({ name, size, color, strokeWidth = 1.8 }: { name: IconName; size:
747
785
  );
748
786
  }
749
787
 
788
+ function useReduceMotion() {
789
+ const [reduce, setReduce] = useState(false);
790
+ useEffect(() => {
791
+ let alive = true;
792
+ AccessibilityInfo.isReduceMotionEnabled()
793
+ .then((r) => alive && setReduce(r))
794
+ .catch(() => {});
795
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
796
+ return () => {
797
+ alive = false;
798
+ sub.remove();
799
+ };
800
+ }, []);
801
+ return reduce;
802
+ }
803
+
750
804
  const styles = StyleSheet.create({
751
805
  field: { borderWidth: 1.5, justifyContent: 'center' },
752
806
  halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 4 },
@@ -775,11 +829,11 @@ const styles = StyleSheet.create({
775
829
  presetScroll: { flexGrow: 0, flexShrink: 0 },
776
830
  calendarScroll: { flexGrow: 0, flexShrink: 1 },
777
831
  presets: { gap: 8, paddingHorizontal: 16, paddingVertical: 8 },
778
- preset: { height: 34, paddingHorizontal: 14, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
832
+ preset: { minHeight: 34, paddingHorizontal: 14, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
779
833
  presetText: { fontSize: 13.5, fontWeight: '400' },
780
834
  calendar: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 6 },
781
835
  footer: { flexDirection: 'row', flexShrink: 0, gap: 8, paddingTop: 12, paddingHorizontal: 16, borderTopWidth: StyleSheet.hairlineWidth },
782
- footBtn: { height: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
836
+ footBtn: { minHeight: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
783
837
  footMain: { flex: 1 },
784
838
  footText: { fontSize: 15, fontWeight: '400' },
785
839
  popover: {
@@ -104,6 +104,9 @@ export interface DialogProps {
104
104
  }
105
105
 
106
106
  const SHAKE = [10, -9, 7, -5, 3, 0];
107
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
108
+ // content text scales all the way.
109
+ const FONT_CAP = 1.35;
107
110
 
108
111
  /**
109
112
  * A centred alert dialog. The scrim fades in while the card springs from 0.92 to full size; an
@@ -325,6 +328,7 @@ export function Dialog({
325
328
  maxLength={prompt.maxLength}
326
329
  selectTextOnFocus={prompt.selectTextOnFocus ?? true}
327
330
  autoFocus
331
+ maxFontSizeMultiplier={FONT_CAP}
328
332
  returnKeyType="done"
329
333
  onSubmitEditing={() => run(list[confirmIndex], confirmIndex)}
330
334
  onFocus={() => setFocused(true)}
@@ -542,7 +546,7 @@ function PillButton({
542
546
  },
543
547
  ]}
544
548
  >
545
- <Text numberOfLines={1} style={[styles.pillText, ff, { color: fg, opacity: loading ? 0 : 1 }]}>
549
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.pillText, ff, { color: fg, opacity: loading ? 0 : 1 }]}>
546
550
  {label}
547
551
  </Text>
548
552
  {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={fg} /> : null}
@@ -587,7 +591,7 @@ function IosButton({
587
591
  style={[styles.iosButton, style]}
588
592
  >
589
593
  <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: highlight, opacity: hl }]} />
590
- <Text numberOfLines={1} style={[styles.iosText, ff, { color, opacity: loading ? 0 : disabled ? 0.4 : 1 }]}>
594
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.iosText, ff, { color, opacity: loading ? 0 : disabled ? 0.4 : 1 }]}>
591
595
  {label}
592
596
  </Text>
593
597
  {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={color} /> : null}
@@ -750,14 +754,14 @@ const styles = StyleSheet.create({
750
754
  textCenter: { textAlign: 'center' },
751
755
  field: { marginTop: 16 },
752
756
  fieldIos: { marginTop: 14 },
753
- input: { height: 48, paddingHorizontal: 14, fontSize: 16, borderWidth: 1 },
754
- inputIos: { height: 36, paddingHorizontal: 10, fontSize: 14, borderWidth: StyleSheet.hairlineWidth },
757
+ input: { minHeight: 48, paddingHorizontal: 14, fontSize: 16, borderWidth: 1 },
758
+ inputIos: { minHeight: 36, paddingHorizontal: 10, fontSize: 14, borderWidth: StyleSheet.hairlineWidth },
755
759
  error: { fontSize: 13, lineHeight: 18, fontWeight: '400', marginTop: 8 },
756
760
  children: { marginTop: 14 },
757
761
  row: { flexDirection: 'row' },
758
762
  flex: { flex: 1 },
759
763
  actions: { gap: 10, padding: 22, paddingTop: 22 },
760
- pill: { height: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
764
+ pill: { minHeight: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
761
765
  pillText: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.1 },
762
766
  iosActions: { borderTopWidth: StyleSheet.hairlineWidth },
763
767
  iosButton: { minHeight: 46, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },
@@ -38,6 +38,10 @@ const INLINE_BOTTOM = Platform.select({ ios: 18, android: 12, default: 10 }) ??
38
38
  const MODAL_TOP = Platform.select({ ios: 52, android: (StatusBar.currentHeight ?? 24) + 8, default: 16 }) ?? 16;
39
39
  const MODAL_BOTTOM = Platform.select({ ios: 28, android: 16, default: 12 }) ?? 12;
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
  /** Panel surface hidden past the screen edge, so overshoot and rubber-banding never show a gap. */
42
46
  const EXTRA = 48;
43
47
  /** Extra travel when closed so the panel's shadow is off-screen too. */
@@ -673,7 +677,7 @@ function ProfileAvatar({ name, src, size, status }: { name: string; src?: string
673
677
  },
674
678
  ]}
675
679
  >
676
- <Text style={[{ color: ink, fontSize: size * 0.36, fontWeight: '400', letterSpacing: -0.2 }, ff]}>{initials}</Text>
680
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize: size * 0.36, fontWeight: '400', letterSpacing: -0.2 }, ff]}>{initials}</Text>
677
681
  {src ? <Image source={{ uri: src }} alt="" style={StyleSheet.absoluteFill} /> : null}
678
682
  </View>
679
683
  {status ? (
@@ -925,7 +929,7 @@ export function DrawerItem({
925
929
  { borderRadius: radius.capsule(22), backgroundColor: badgeColors.bg, transform: [{ scale: badgePop }] },
926
930
  ]}
927
931
  >
928
- <Text style={[styles.badgeText, ff, { color: badgeColors.fg }]}>
932
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.badgeText, ff, { color: badgeColors.fg }]}>
929
933
  {typeof badge === 'number' && badge > 99 ? '99+' : badge}
930
934
  </Text>
931
935
  </Animated.View>
@@ -1117,7 +1121,7 @@ const styles = StyleSheet.create({
1117
1121
  item: { minHeight: ITEM_H, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, gap: 14 },
1118
1122
  itemIcon: { width: 24, alignItems: 'center', justifyContent: 'center' },
1119
1123
  itemLabel: { flex: 1, fontSize: 15.5, fontWeight: '400', letterSpacing: -0.15 },
1120
- badge: { minWidth: 22, height: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
1124
+ badge: { minWidth: 22, minHeight: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
1121
1125
  badgePlain: { paddingHorizontal: 2, minWidth: 0 },
1122
1126
  badgeText: { fontSize: 13, fontWeight: '400', fontVariant: ['tabular-nums'] },
1123
1127
  section: {
@@ -1131,7 +1135,7 @@ const styles = StyleSheet.create({
1131
1135
  paddingBottom: 2,
1132
1136
  },
1133
1137
  sectionFlush: { paddingTop: 10 },
1134
- sectionText: { fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0.1 },
1138
+ sectionText: { flexShrink: 1, fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0.1 },
1135
1139
  divider: { height: StyleSheet.hairlineWidth, marginHorizontal: 14, marginTop: 10, marginBottom: 4 },
1136
1140
  footerRow: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 12, paddingVertical: 8 },
1137
1141
  footerText: { flex: 1, minWidth: 0, gap: 5 },
@@ -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 },