@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,5 +1,5 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
- import { Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import Svg, { Path } from 'react-native-svg';
4
4
 
5
5
  import { alpha, useKit } from '@/lib/kit';
@@ -38,7 +38,9 @@ export function Checkbox({
38
38
  shape = 'square',
39
39
  style,
40
40
  }: CheckboxProps) {
41
- const { colors, radius } = useKit();
41
+ const { colors, radius, font } = useKit();
42
+ const reduce = useReduceMotion();
43
+ const ff = font.family ? { fontFamily: font.family } : null;
42
44
  const tint = accent ?? colors.accent;
43
45
  const corner = shape === 'circle' ? size / 2 : Math.min(size / 2, radius.sm + 1);
44
46
  const [inner, setInner] = useState(defaultChecked);
@@ -49,12 +51,17 @@ export function Checkbox({
49
51
  const burst = useRef(new Animated.Value(0)).current;
50
52
 
51
53
  useEffect(() => {
52
- Animated.spring(progress, { toValue: on ? 1 : 0, useNativeDriver: false, speed: 14, bounciness: 6 }).start();
54
+ // Reduce Motion: a short fill fade, no burst ring and no press squish.
55
+ if (reduce) {
56
+ Animated.timing(progress, { toValue: on ? 1 : 0, duration: 150, useNativeDriver: false }).start();
57
+ return;
58
+ }
59
+ Animated.spring(progress, { toValue: on ? 1 : 0, useNativeDriver: false, speed: 14, bounciness: 0 }).start();
53
60
  if (on) {
54
61
  burst.setValue(0);
55
62
  Animated.timing(burst, { toValue: 1, duration: 450, useNativeDriver: true }).start();
56
63
  }
57
- }, [on, progress, burst]);
64
+ }, [on, progress, burst, reduce]);
58
65
 
59
66
  const toggle = () => {
60
67
  if (checked === undefined) setInner(!on);
@@ -71,8 +78,8 @@ export function Checkbox({
71
78
  accessibilityState={{ checked: on, disabled }}
72
79
  disabled={disabled}
73
80
  onPress={toggle}
74
- onPressIn={() => Animated.spring(press, { toValue: 0.82, useNativeDriver: true, speed: 50 }).start()}
75
- onPressOut={() => Animated.spring(press, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 14 }).start()}
81
+ onPressIn={() => !reduce && Animated.spring(press, { toValue: 0.82, useNativeDriver: true, speed: 50, bounciness: 0 }).start()}
82
+ onPressOut={() => !reduce && Animated.spring(press, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 0 }).start()}
76
83
  style={[styles.row, { opacity: disabled ? 0.5 : 1 }, style]}
77
84
  >
78
85
  <Animated.View style={{ transform: [{ scale: press }] }}>
@@ -119,7 +126,7 @@ export function Checkbox({
119
126
  {label ? (
120
127
  <View style={styles.text}>
121
128
  <View style={styles.labelBox}>
122
- <Animated.Text style={[styles.label, { color: progress.interpolate({ inputRange: [0, 1], outputRange: [fg, strike ? muted : fg] }) }]}>
129
+ <Animated.Text style={[styles.label, ff, { color: progress.interpolate({ inputRange: [0, 1], outputRange: [fg, strike ? muted : fg] }) }]}>
123
130
  {label}
124
131
  </Animated.Text>
125
132
  {strike ? (
@@ -131,13 +138,29 @@ export function Checkbox({
131
138
  />
132
139
  ) : null}
133
140
  </View>
134
- {description ? <Text style={[styles.description, { color: muted }]}>{description}</Text> : null}
141
+ {description ? <Text style={[styles.description, ff, { color: muted }]}>{description}</Text> : null}
135
142
  </View>
136
143
  ) : null}
137
144
  </Pressable>
138
145
  );
139
146
  }
140
147
 
148
+ function useReduceMotion() {
149
+ const [reduce, setReduce] = useState(false);
150
+ useEffect(() => {
151
+ let alive = true;
152
+ AccessibilityInfo.isReduceMotionEnabled()
153
+ .then((r) => alive && setReduce(r))
154
+ .catch(() => {});
155
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
156
+ return () => {
157
+ alive = false;
158
+ sub.remove();
159
+ };
160
+ }, []);
161
+ return reduce;
162
+ }
163
+
141
164
  const styles = StyleSheet.create({
142
165
  row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
143
166
  box: { borderWidth: 2, alignItems: 'center', 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,5 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Pressable,
5
6
  ScrollView,
@@ -56,6 +57,10 @@ export interface ChipGroupProps {
56
57
  style?: StyleProp<ViewStyle>;
57
58
  }
58
59
 
60
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
61
+ // content text scales all the way.
62
+ const FONT_CAP = 1.35;
63
+
59
64
  const SIZES: Record<ChipGroupSize, { h: number; px: number; font: number; check: number }> = {
60
65
  sm: { h: 30, px: 12, font: 13, check: 14 },
61
66
  md: { h: 36, px: 14, font: 14.5, check: 16 },
@@ -84,6 +89,7 @@ export function ChipGroup({
84
89
  style,
85
90
  }: ChipGroupProps) {
86
91
  const { colors } = useKit();
92
+ const reduce = useReduceMotion();
87
93
  const tint = accent ?? colors.accent;
88
94
  const items: ChipOption[] = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
89
95
  const [inner, setInner] = useState<string[]>(defaultValue);
@@ -114,6 +120,7 @@ export function ChipGroup({
114
120
  showCheck={showCheck}
115
121
  multiple={multiple}
116
122
  disabled={disabled || o.disabled}
123
+ reduce={reduce}
117
124
  />
118
125
  ));
119
126
 
@@ -148,20 +155,26 @@ interface ChipProps {
148
155
  showCheck: boolean;
149
156
  multiple: boolean;
150
157
  disabled?: boolean;
158
+ reduce: boolean;
151
159
  }
152
160
 
153
- function Chip({ option, active, onPress, variant, size, tint, showCheck, multiple, disabled }: ChipProps) {
154
- const { colors, radius } = useKit();
161
+ function Chip({ option, active, onPress, variant, size, tint, showCheck, multiple, disabled, reduce }: ChipProps) {
162
+ const { colors, radius, font } = useKit();
163
+ const ff = font.family ? { fontFamily: font.family } : null;
155
164
  // p: colours, check width + stroke (JS driver). press: squish (native, on the outer view).
156
165
  const p = useRef(new Animated.Value(active ? 1 : 0)).current;
157
166
  const press = useRef(new Animated.Value(1)).current;
158
167
 
159
168
  useEffect(() => {
160
- Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
161
- }, [active, p]);
169
+ // Reduce Motion: the chip swaps state at once (no widening, no check draw).
170
+ if (reduce) p.setValue(active ? 1 : 0);
171
+ else Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
172
+ }, [active, p, reduce]);
162
173
 
163
- const pressTo = (to: number) =>
174
+ const pressTo = (to: number) => {
175
+ if (reduce) return;
164
176
  Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();
177
+ };
165
178
 
166
179
  const look: Record<ChipGroupVariant, { bg: [string, string]; border: [string, string]; fg: [string, string] }> = {
167
180
  solid: {
@@ -200,7 +213,7 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
200
213
  style={[
201
214
  styles.chip,
202
215
  {
203
- height: size.h,
216
+ minHeight: size.h,
204
217
  borderRadius: radius.capsule(size.h),
205
218
  paddingHorizontal: size.px - 1,
206
219
  backgroundColor: p.interpolate({ ...range, outputRange: l.bg }),
@@ -237,15 +250,18 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
237
250
  {option.label ? (
238
251
  <Animated.Text
239
252
  numberOfLines={1}
240
- style={[styles.label, { fontSize: size.font, color: p.interpolate({ ...range, outputRange: l.fg }) }]}
253
+ maxFontSizeMultiplier={FONT_CAP}
254
+ style={[styles.label, ff, { fontSize: size.font, color: p.interpolate({ ...range, outputRange: l.fg }) }]}
241
255
  >
242
256
  {option.label}
243
257
  </Animated.Text>
244
258
  ) : null}
245
259
  {option.count !== undefined ? (
246
260
  <Animated.Text
261
+ maxFontSizeMultiplier={FONT_CAP}
247
262
  style={[
248
263
  styles.count,
264
+ ff,
249
265
  { fontSize: size.font - 1.5, color: p.interpolate({ ...range, outputRange: [colors.mutedForeground, l.fg[1]] }) },
250
266
  ]}
251
267
  >
@@ -258,6 +274,22 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
258
274
  );
259
275
  }
260
276
 
277
+ function useReduceMotion() {
278
+ const [reduce, setReduce] = useState(false);
279
+ useEffect(() => {
280
+ let alive = true;
281
+ AccessibilityInfo.isReduceMotionEnabled()
282
+ .then((r) => alive && setReduce(r))
283
+ .catch(() => {});
284
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
285
+ return () => {
286
+ alive = false;
287
+ sub.remove();
288
+ };
289
+ }, []);
290
+ return reduce;
291
+ }
292
+
261
293
  const styles = StyleSheet.create({
262
294
  row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
263
295
  wrap: { flexWrap: 'wrap' },
@@ -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
  };
@@ -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 React, { useEffect, useRef, useState } from 'react';
2
- import { Animated, Easing, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Easing, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import Svg, { Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
4
4
 
5
5
  import { FlipCard, useFlipProgress } from '@/components/ui/flip-card';
@@ -170,6 +170,10 @@ function contrast(a: string, b: string) {
170
170
  return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
171
171
  }
172
172
 
173
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
174
+ // content text scales all the way.
175
+ const FONT_CAP = 1.35;
176
+
173
177
  const DAY = 86_400_000;
174
178
  const pad2 = (n: number) => String(n).padStart(2, '0');
175
179
 
@@ -197,7 +201,9 @@ function useRemaining(at?: Date | number) {
197
201
  }
198
202
 
199
203
  /** The promo code in a dashed chip; a tap copies it and flips the icon to a check for a moment. */
200
- function CodeChip({ code, color, ink, fontSize, onCopy, labels }: { code: string; color: string; ink: string; fontSize: number; onCopy?: (code: string) => void; labels: Required<CouponLabels> }) {
204
+ function CodeChip({ code, color, ink, fontSize, onCopy, labels, reduce }: { code: string; color: string; ink: string; fontSize: number; onCopy?: (code: string) => void; labels: Required<CouponLabels>; reduce: boolean }) {
205
+ const { font } = useKit();
206
+ const ff = font.family ? { fontFamily: font.family } : null;
201
207
  const [copied, setCopied] = useState(false);
202
208
  const [box, setBox] = useState({ w: 0, h: 0 });
203
209
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -206,8 +212,10 @@ function CodeChip({ code, color, ink, fontSize, onCopy, labels }: { code: string
206
212
  const copy = () => {
207
213
  onCopy?.(code);
208
214
  setCopied(true);
209
- pop.setValue(0.4);
210
- Animated.spring(pop, { toValue: 1, ...motion.bouncy, useNativeDriver: true }).start();
215
+ if (!reduce) {
216
+ pop.setValue(0.4);
217
+ Animated.spring(pop, { toValue: 1, ...motion.bouncy, useNativeDriver: true }).start();
218
+ }
211
219
  clearTimeout(timer.current);
212
220
  timer.current = setTimeout(() => setCopied(false), 1600);
213
221
  };
@@ -226,7 +234,7 @@ function CodeChip({ code, color, ink, fontSize, onCopy, labels }: { code: string
226
234
  <Rect x={0.75} y={0.75} width={box.w - 1.5} height={box.h - 1.5} rx={8} fill="none" stroke={alpha(color, 0.75)} strokeWidth={1.5} strokeDasharray={[5, 4]} />
227
235
  </Svg>
228
236
  ) : null}
229
- <Text numberOfLines={1} style={{ color: ink, fontSize, fontWeight: '800', letterSpacing: 1, flexShrink: 1 }}>
237
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize, fontWeight: '800', letterSpacing: 1, flexShrink: 1 }, ff]}>
230
238
  {code}
231
239
  </Text>
232
240
  {onCopy ? (
@@ -325,7 +333,9 @@ export function Coupon({
325
333
  labels: labelsProp,
326
334
  style,
327
335
  }: CouponProps) {
328
- const { colors, dark } = useKit();
336
+ const { colors, dark, font } = useKit();
337
+ const ff = font.family ? { fontFamily: font.family } : null;
338
+ const reduce = useReduceMotion();
329
339
  const theme = typeof themeProp === 'object' ? themeProp : couponThemes[themeProp];
330
340
  const fills = typeof theme.fill === 'string' ? [theme.fill] : theme.fill;
331
341
  const accent = theme.accent ?? fills[fills.length - 1];
@@ -421,7 +431,7 @@ export function Coupon({
421
431
  left !== undefined && left > 0 && !spent ? (
422
432
  <View style={[styles.timer, { backgroundColor: timerBg }]}>
423
433
  <Icon d={ICON.clock} size={11} color={timerInk} />
424
- <Text style={{ color: timerInk, fontSize: 10.5, fontWeight: '800', fontVariant: ['tabular-nums'] }}>
434
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: timerInk, fontSize: 10.5, fontWeight: '800', fontVariant: ['tabular-nums'] }, ff]}>
425
435
  {labels.endsIn} {formatCountdown(left)}
426
436
  </Text>
427
437
  </View>
@@ -433,7 +443,7 @@ export function Coupon({
433
443
  <View style={[styles.row, { gap: 6, flexShrink: 1 }]}>
434
444
  {logo}
435
445
  {brand ? (
436
- <Text numberOfLines={1} style={{ color: horizontal ? bodySoft : valueSoft, fontSize: 10.5, fontWeight: '800', letterSpacing: 1.2, flexShrink: 1 }}>
446
+ <Text numberOfLines={1} style={[{ color: horizontal ? bodySoft : valueSoft, fontSize: 10.5, fontWeight: '800', letterSpacing: 1.2, flexShrink: 1 }, ff]}>
437
447
  {brand.toUpperCase()}
438
448
  </Text>
439
449
  ) : null}
@@ -445,15 +455,15 @@ export function Coupon({
445
455
  // Value panel ------------------------------------------------------------------------------------
446
456
  const valueBlock = (
447
457
  <View style={{ alignItems: 'center' }}>
448
- {prefix ? <Text style={[{ color: valueSoft, fontSize: 10.5, fontWeight: '900', letterSpacing: 1.6 }, lift]}>{prefix.toUpperCase()}</Text> : null}
458
+ {prefix ? <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: valueSoft, fontSize: 10.5, fontWeight: '900', letterSpacing: 1.6 }, ff, lift]}>{prefix.toUpperCase()}</Text> : null}
449
459
  {locked ? <Icon d={ICON.lock} size={valueSize * 0.6} color={valueInk} /> : null}
450
460
  {/* Vertical coupons set "50% OFF" on one line when it fits, and stack a long unit ("CASHBACK"). */}
451
461
  <View style={{ flexDirection: inline ? 'row' : 'column', alignItems: inline ? 'flex-end' : 'center', gap: inline ? 6 : 0 }}>
452
- <Text numberOfLines={1} adjustsFontSizeToFit style={[{ color: valueInk, fontSize: valueSize, lineHeight: valueSize * 1.08, fontWeight: '900', letterSpacing: -1.2 }, lift]}>
462
+ <Text numberOfLines={1} adjustsFontSizeToFit maxFontSizeMultiplier={FONT_CAP} style={[{ color: valueInk, fontSize: valueSize, lineHeight: valueSize * 1.08, fontWeight: '900', letterSpacing: -1.2 }, ff, lift]}>
453
463
  {value}
454
464
  </Text>
455
465
  {unit ? (
456
- <Text style={[{ color: valueInk, fontSize: horizontal ? 12.5 : 16, fontWeight: '900', letterSpacing: 2.4, marginBottom: inline ? valueSize * 0.14 : 0 }, lift]}>
466
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: valueInk, fontSize: horizontal ? 12.5 : 16, fontWeight: '900', letterSpacing: 2.4, marginBottom: inline ? valueSize * 0.14 : 0 }, ff, lift]}>
457
467
  {unit.toUpperCase()}
458
468
  </Text>
459
469
  ) : null}
@@ -482,7 +492,7 @@ export function Coupon({
482
492
  {brandRow}
483
493
  {valueBlock}
484
494
  {title ? (
485
- <Text numberOfLines={2} style={[{ color: valueInk, fontSize: s.title + 1, fontWeight: '800', textAlign: 'center', letterSpacing: -0.3 }, lift]}>
495
+ <Text numberOfLines={2} style={[{ color: valueInk, fontSize: s.title + 1, fontWeight: '800', textAlign: 'center', letterSpacing: -0.3 }, ff, lift]}>
486
496
  {title}
487
497
  </Text>
488
498
  ) : null}
@@ -505,13 +515,13 @@ export function Coupon({
505
515
  {
506
516
  marginLeft: 'auto',
507
517
  backgroundColor: applied ? '#16a34a' : locked || spent ? alpha(bodyInk, 0.12) : accent,
508
- transform: [{ scale: pressed ? 0.95 : 1 }],
518
+ transform: [{ scale: pressed && !reduce ? 0.95 : 1 }],
509
519
  paddingHorizontal: size === 'sm' ? 11 : size === 'md' ? 13 : 16,
510
520
  },
511
521
  ]}
512
522
  >
513
523
  {applied || locked ? <Icon d={applied ? ICON.check : ICON.lock} size={13} color={applied ? '#ffffff' : bodySoft} /> : null}
514
- <Text numberOfLines={1} style={{ color: applied ? '#ffffff' : locked || spent ? bodySoft : readableOn(accent), fontSize: s.chip - 0.5, fontWeight: '800' }}>
524
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: applied ? '#ffffff' : locked || spent ? bodySoft : readableOn(accent), fontSize: s.chip - 0.5, fontWeight: '800' }, ff]}>
515
525
  {applied ? labels.applied : locked ? labels.locked : labels.apply}
516
526
  </Text>
517
527
  </Pressable>
@@ -520,11 +530,11 @@ export function Coupon({
520
530
  const footer =
521
531
  expires || canFlip ? (
522
532
  <View style={[styles.row, { justifyContent: 'flex-start', gap: 6 }]}>
523
- {expires ? <Text style={{ color: bodySoft, fontSize: 11, fontWeight: '600' }}>{expires}</Text> : null}
524
- {expires && canFlip ? <Text style={{ color: bodySoft, fontSize: 11 }}>·</Text> : null}
533
+ {expires ? <Text style={[{ color: bodySoft, fontSize: 11, fontWeight: '600', flexShrink: 1 }, ff]}>{expires}</Text> : null}
534
+ {expires && canFlip ? <Text style={[{ color: bodySoft, fontSize: 11 }, ff]}>·</Text> : null}
525
535
  {canFlip ? (
526
536
  <Pressable onPress={toggle} hitSlop={10} accessibilityRole="button" accessibilityLabel={labels.termsTitle}>
527
- <Text style={{ color: textAccent, fontSize: 11, fontWeight: '800', textDecorationLine: 'underline' }}>{labels.terms}</Text>
537
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: textAccent, fontSize: 11, fontWeight: '800', textDecorationLine: 'underline' }, ff]}>{labels.terms}</Text>
528
538
  </Pressable>
529
539
  ) : null}
530
540
  </View>
@@ -552,16 +562,16 @@ export function Coupon({
552
562
  >
553
563
  {horizontal ? brandRow : null}
554
564
  {horizontal && title ? (
555
- <Text numberOfLines={2} style={{ color: bodyInk, fontSize: s.title, fontWeight: '800', letterSpacing: -0.3 }}>
565
+ <Text numberOfLines={2} style={[{ color: bodyInk, fontSize: s.title, fontWeight: '800', letterSpacing: -0.3 }, ff]}>
556
566
  {title}
557
567
  </Text>
558
568
  ) : null}
559
569
  {locked && lockedHint ? (
560
- <Text numberOfLines={2} style={{ color: textAccent, fontSize: s.desc, fontWeight: '700' }}>
570
+ <Text numberOfLines={2} style={[{ color: textAccent, fontSize: s.desc, fontWeight: '700' }, ff]}>
561
571
  {lockedHint}
562
572
  </Text>
563
573
  ) : description ? (
564
- <Text numberOfLines={size === 'sm' ? 1 : 2} style={{ color: bodySoft, fontSize: s.desc, lineHeight: s.desc * 1.35 }}>
574
+ <Text numberOfLines={size === 'sm' ? 1 : 2} style={[{ color: bodySoft, fontSize: s.desc, lineHeight: s.desc * 1.35 }, ff]}>
565
575
  {description}
566
576
  </Text>
567
577
  ) : null}
@@ -569,7 +579,7 @@ export function Coupon({
569
579
  // The code never gets cut short: when it and the button don't fit side by side, the button
570
580
  // wraps under it (and stays right-aligned).
571
581
  <View style={[styles.row, { gap: 8, marginTop: 2, flexWrap: 'wrap' }]}>
572
- {code ? <CodeChip code={code} color={textAccent} ink={bodyInk} fontSize={s.chip} onCopy={spent || locked ? undefined : onCopy} labels={labels} /> : <View />}
582
+ {code ? <CodeChip code={code} color={textAccent} ink={bodyInk} fontSize={s.chip} onCopy={spent || locked ? undefined : onCopy} labels={labels} reduce={reduce} /> : <View />}
573
583
  {action}
574
584
  </View>
575
585
  ) : null}
@@ -579,7 +589,8 @@ export function Coupon({
579
589
 
580
590
  const { width: _w, height: _h, ...shape } = outline;
581
591
  const glareClip = measured ? ticketSafeRects(outline) : undefined;
582
- const valueWindows = shine && measured && !spent && !locked
592
+ // The sweep is decorative, so Reduce Motion drops it.
593
+ const valueWindows = shine && !reduce && measured && !spent && !locked
583
594
  ? ticketSafeRects({ width: valueBox.width, height: valueBox.height, corners: valueCorners, edges: valueEdges, edgeSize: v.edgeSize })
584
595
  : null;
585
596
 
@@ -627,23 +638,23 @@ export function Coupon({
627
638
  <TicketShape {...shape} fill={body} style={StyleSheet.absoluteFill}>
628
639
  <Pressable onPress={toggle} accessibilityRole="button" accessibilityHint={labels.back} style={[StyleSheet.absoluteFill, { padding: s.pad + edgePad, gap: 6, flexDirection: horizontal ? 'row' : 'column' }]}>
629
640
  <View style={{ flex: 1, gap: 4 }}>
630
- <Text style={{ color: bodyInk, fontSize: 12, fontWeight: '800', letterSpacing: 0.3 }}>{labels.termsTitle}</Text>
641
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: bodyInk, fontSize: 12, fontWeight: '800', letterSpacing: 0.3 }, ff]}>{labels.termsTitle}</Text>
631
642
  {terms!.slice(0, size === 'lg' ? 5 : 4).map((t, i) => (
632
643
  <View key={i} style={{ flexDirection: 'row', gap: 6 }}>
633
- <Text style={{ color: textAccent, fontSize: 11, fontWeight: '900' }}>•</Text>
634
- <Text numberOfLines={2} style={{ color: bodySoft, fontSize: 11, lineHeight: 14.5, flex: 1 }}>
644
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: textAccent, fontSize: 11, fontWeight: '900' }, ff]}>•</Text>
645
+ <Text numberOfLines={2} maxFontSizeMultiplier={FONT_CAP} style={[{ color: bodySoft, fontSize: 11, lineHeight: 14.5, flex: 1 }, ff]}>
635
646
  {t}
636
647
  </Text>
637
648
  </View>
638
649
  ))}
639
- <Text style={{ color: alpha(bodyInk, 0.4), fontSize: 10, fontWeight: '700', marginTop: 'auto' }}>{labels.back}</Text>
650
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: alpha(bodyInk, 0.4), fontSize: 10, fontWeight: '700', marginTop: 'auto' }, ff]}>{labels.back}</Text>
640
651
  </View>
641
652
  {code ? (
642
653
  <View style={{ alignItems: 'center', justifyContent: 'center', gap: 4 }}>
643
654
  <View style={styles.plate}>
644
655
  <BarcodeLike code={code} width={horizontal ? 74 : 200} height={horizontal ? 54 : 46} />
645
656
  </View>
646
- <Text style={{ color: bodyInk, fontSize: 10, fontWeight: '800', letterSpacing: 1.2 }}>{code}</Text>
657
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: bodyInk, fontSize: 10, fontWeight: '800', letterSpacing: 1.2 }, ff]}>{code}</Text>
647
658
  </View>
648
659
  ) : null}
649
660
  </Pressable>
@@ -665,7 +676,7 @@ export function Coupon({
665
676
  return (
666
677
  <Pressable onPress={onPress} disabled={!onPress} accessibilityRole={onPress ? 'button' : undefined} style={style}>
667
678
  {({ pressed }) => (
668
- <View style={{ transform: [{ scale: pressed ? 0.985 : 1 }] }}>
679
+ <View style={{ transform: [{ scale: pressed && !reduce ? 0.985 : 1 }] }}>
669
680
  {/* The plate waits for the painted outline (no black flash on mount), and a dimmed (used /
670
681
  expired) coupon is see-through, so it gets none. */}
671
682
  {spent || !measured ? null : <View pointerEvents="none" style={[styles.shadow, { top: inset, bottom: inset - 4, left: inset, right: inset, borderRadius: v.r, shadowOpacity: dark ? 0.5 : 0.2 }]} />}
@@ -677,6 +688,22 @@ export function Coupon({
677
688
  );
678
689
  }
679
690
 
691
+ function useReduceMotion() {
692
+ const [reduce, setReduce] = useState(false);
693
+ useEffect(() => {
694
+ let alive = true;
695
+ AccessibilityInfo.isReduceMotionEnabled()
696
+ .then((r) => alive && setReduce(r))
697
+ .catch(() => {});
698
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
699
+ return () => {
700
+ alive = false;
701
+ sub.remove();
702
+ };
703
+ }, []);
704
+ return reduce;
705
+ }
706
+
680
707
  const styles = StyleSheet.create({
681
708
  row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
682
709
  center: { alignItems: 'center', justifyContent: 'center' },
@@ -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
  }
@@ -121,7 +124,7 @@ export function FlipCard({
121
124
  borderRadius: radius,
122
125
  opacity,
123
126
  transform: [{ perspective }, { [axis]: turn } as never, { scale: lift }],
124
- }) as Animated.WithAnimatedObject<ViewStyle>;
127
+ }) as Animated.WithAnimatedValue<ViewStyle>;
125
128
 
126
129
  // The shadow squeezes along the turning axis as the card goes edge-on (see SQUEEZE) and fades a little.
127
130
  const squeeze = p.interpolate(SQUEEZE);
@@ -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 },