@expokit/cli 0.4.2 → 0.4.4

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 +6 -2
  2. package/bin/expo-kit.js +26 -3
  3. package/bin/lib.mjs +17 -1
  4. package/package.json +1 -1
  5. package/registry/_docs/flip-calendar.json +106 -0
  6. package/registry/_docs/paywall.json +144 -0
  7. package/registry/_docs/ruler-time-picker.json +147 -0
  8. package/registry/accordion/files/src/lib/kit.tsx +102 -4
  9. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +8 -11
  10. package/registry/action-sheet/files/src/lib/kit.tsx +102 -4
  11. package/registry/alert/files/src/components/ui/alert.tsx +1 -1
  12. package/registry/alert/files/src/components/ui/button.tsx +5 -18
  13. package/registry/alert/files/src/lib/kit.tsx +102 -4
  14. package/registry/avatar/files/src/components/ui/avatar.tsx +5 -8
  15. package/registry/avatar/files/src/lib/kit.tsx +102 -4
  16. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  17. package/registry/badge/files/src/lib/kit.tsx +102 -4
  18. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -17
  19. package/registry/bottom-nav/files/src/lib/kit.tsx +102 -4
  20. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -14
  21. package/registry/bottom-sheet/files/src/lib/kit.tsx +102 -4
  22. package/registry/button/files/src/components/ui/button.tsx +5 -18
  23. package/registry/button/files/src/lib/kit.tsx +102 -4
  24. package/registry/button-3d/files/src/components/ui/button-3d.tsx +4 -7
  25. package/registry/button-3d/files/src/lib/kit.tsx +102 -4
  26. package/registry/calendar/files/src/components/ui/calendar.tsx +3 -3
  27. package/registry/calendar/files/src/lib/kit.tsx +102 -4
  28. package/registry/card/files/src/components/ui/card.tsx +4 -3
  29. package/registry/card/files/src/lib/kit.tsx +102 -4
  30. package/registry/checkbox/files/src/lib/kit.tsx +102 -4
  31. package/registry/chip-group/files/src/components/ui/chip-group.tsx +2 -2
  32. package/registry/chip-group/files/src/lib/kit.tsx +102 -4
  33. package/registry/confetti/files/src/lib/kit.tsx +102 -4
  34. package/registry/coupon/files/src/components/ui/coupon.tsx +5 -10
  35. package/registry/coupon/files/src/components/ui/flip-card.tsx +3 -9
  36. package/registry/coupon/files/src/components/ui/tilt-card.tsx +4 -10
  37. package/registry/coupon/files/src/lib/kit.tsx +102 -4
  38. package/registry/date-picker/files/src/components/ui/calendar.tsx +3 -3
  39. package/registry/date-picker/files/src/components/ui/date-picker.tsx +10 -16
  40. package/registry/date-picker/files/src/lib/kit.tsx +102 -4
  41. package/registry/dialog/files/src/components/ui/dialog.tsx +6 -12
  42. package/registry/dialog/files/src/lib/kit.tsx +102 -4
  43. package/registry/drawer/files/src/components/ui/drawer.tsx +3 -19
  44. package/registry/drawer/files/src/lib/kit.tsx +102 -4
  45. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +626 -0
  46. package/registry/flip-calendar/files/src/lib/kit.tsx +320 -0
  47. package/registry/flip-calendar.json +17 -0
  48. package/registry/flip-card/files/src/components/ui/flip-card.tsx +3 -9
  49. package/registry/flip-card/files/src/lib/kit.tsx +102 -4
  50. package/registry/floating-input/files/src/lib/kit.tsx +102 -4
  51. package/registry/grain/files/src/lib/kit.tsx +102 -4
  52. package/registry/kit/files/src/lib/kit.tsx +102 -4
  53. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +6 -7
  54. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -17
  55. package/registry/list-item/files/src/components/ui/list-item.tsx +1 -1
  56. package/registry/list-item/files/src/lib/kit.tsx +102 -4
  57. package/registry/otp-input/files/src/components/ui/otp-input.tsx +1 -1
  58. package/registry/otp-input/files/src/lib/kit.tsx +102 -4
  59. package/registry/password-input/files/src/lib/kit.tsx +102 -4
  60. package/registry/payment-card/files/src/components/ui/flip-card.tsx +3 -9
  61. package/registry/payment-card/files/src/components/ui/payment-card.tsx +9 -3
  62. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +4 -10
  63. package/registry/payment-card/files/src/lib/kit.tsx +102 -4
  64. package/registry/paywall.json +22 -0
  65. package/registry/progress/files/src/components/ui/progress.tsx +2 -0
  66. package/registry/progress/files/src/lib/kit.tsx +102 -4
  67. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +1 -1
  68. package/registry/progress-steps/files/src/lib/kit.tsx +102 -4
  69. package/registry/radio-group/files/src/lib/kit.tsx +102 -4
  70. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +816 -0
  71. package/registry/ruler-time-picker/files/src/lib/kit.tsx +320 -0
  72. package/registry/ruler-time-picker.json +19 -0
  73. package/registry/search-bar/files/src/components/ui/search-bar.tsx +3 -7
  74. package/registry/search-bar/files/src/lib/kit.tsx +102 -4
  75. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +3 -10
  76. package/registry/segmented-control/files/src/lib/kit.tsx +102 -4
  77. package/registry/select/files/src/components/ui/select.tsx +16 -18
  78. package/registry/select/files/src/lib/kit.tsx +102 -4
  79. package/registry/skeleton/files/src/lib/kit.tsx +102 -4
  80. package/registry/slider/files/src/components/ui/slider.tsx +4 -13
  81. package/registry/slider/files/src/lib/kit.tsx +102 -4
  82. package/registry/spinner/files/src/lib/kit.tsx +102 -4
  83. package/registry/stateful-button/files/src/lib/kit.tsx +102 -4
  84. package/registry/stepper/files/src/components/ui/stepper.tsx +2 -2
  85. package/registry/stepper/files/src/lib/kit.tsx +102 -4
  86. package/registry/switch/files/src/components/ui/switch.tsx +2 -5
  87. package/registry/switch/files/src/lib/kit.tsx +102 -4
  88. package/registry/tabs/files/src/components/ui/tabs.tsx +2 -2
  89. package/registry/tabs/files/src/lib/kit.tsx +102 -4
  90. package/registry/text-animation/files/src/lib/kit.tsx +102 -4
  91. package/registry/textarea/files/src/lib/kit.tsx +102 -4
  92. package/registry/ticket-shape/files/src/lib/kit.tsx +102 -4
  93. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +4 -10
  94. package/registry/tilt-card/files/src/lib/kit.tsx +102 -4
  95. package/registry/time-picker/files/src/components/ui/time-picker.tsx +16 -14
  96. package/registry/time-picker/files/src/lib/kit.tsx +102 -4
  97. package/registry/timeline/files/src/components/ui/avatar.tsx +5 -8
  98. package/registry/timeline/files/src/components/ui/timeline.tsx +5 -16
  99. package/registry/timeline/files/src/lib/kit.tsx +102 -4
  100. package/registry/toast/files/src/components/ui/button.tsx +5 -18
  101. package/registry/toast/files/src/components/ui/toast.tsx +2 -2
  102. package/registry/toast/files/src/lib/kit.tsx +102 -4
@@ -84,7 +84,7 @@ export function Card({
84
84
  ...shadow(dark ? 0 : 0.04, 2, 6),
85
85
  },
86
86
  outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
87
- elevated: { backgroundColor: dark ? colors.popover : colors.card, ...shadow(dark ? 0.5 : 0.1, 10, 28, 8) },
87
+ elevated: { backgroundColor: dark ? colors.popover : colors.card, ...shadow(dark ? 0.5 : 0.1, 10, 28) },
88
88
  };
89
89
 
90
90
  const border = (surfaces[variant].borderWidth as number | undefined) ?? 0;
@@ -287,9 +287,10 @@ Card.Content = CardContent;
287
287
  Card.Footer = CardFooter;
288
288
  Card.Media = CardMedia;
289
289
 
290
- function shadow(opacity: number, y: number, blur: number, elevation = 2): ViewStyle {
290
+ /** A boxShadow: the same soft shadow on iOS, Android and web (unlike elevation). */
291
+ function shadow(opacity: number, y: number, blur: number): ViewStyle {
291
292
  if (!opacity) return {};
292
- return { shadowColor: '#000', shadowOpacity: opacity, shadowRadius: blur / 2, shadowOffset: { width: 0, height: y }, elevation };
293
+ return { boxShadow: `0px ${y}px ${blur}px rgba(0,0,0,${opacity})` };
293
294
  }
294
295
 
295
296
  function useReduceMotion() {
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react';
2
- import { Platform, useColorScheme, type TextStyle } from 'react-native';
2
+ import { Platform, useColorScheme, useWindowDimensions, type TextStyle, type ViewStyle } from 'react-native';
3
3
 
4
4
  /**
5
5
  * ExpoKit design tokens. Every component reads them through useKit(), so one
@@ -8,10 +8,10 @@ import { Platform, useColorScheme, type TextStyle } from 'react-native';
8
8
  */
9
9
 
10
10
  /**
11
- * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule).
12
- * The CLI won't keep an edited kit.tsx older than the component being added needs.
11
+ * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule,
12
+ * 3: useLayout / shadow). The CLI won't keep an edited kit.tsx older than the component being added needs.
13
13
  */
14
- export const KIT_VERSION = 2;
14
+ export const KIT_VERSION = 3;
15
15
 
16
16
  export type ColorScheme = 'light' | 'dark';
17
17
 
@@ -58,6 +58,8 @@ export interface Theme {
58
58
  };
59
59
  /** 4pt spacing scale: space(4) === 16. */
60
60
  space: (n: number) => number;
61
+ /** The same soft shadow on iOS, Android and web: `style={[styles.card, shadow('md')]}`. */
62
+ shadow: (level: ShadowLevel, color?: string) => ViewStyle;
61
63
  font: { family?: string; mono: string };
62
64
  text: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl', TextStyle>;
63
65
  }
@@ -134,6 +136,7 @@ function build(scheme: ColorScheme, overrides?: ThemeOverrides): Theme {
134
136
  capsule: (h) => Math.round((h / 2) * Math.min(1, r / 12)),
135
137
  },
136
138
  space: (n) => n * 4,
139
+ shadow: (level, color) => shadowFor(level, scheme === 'dark', color),
137
140
  font: {
138
141
  family: overrides?.fontFamily,
139
142
  mono: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' })!,
@@ -181,6 +184,101 @@ export function useKit(): Theme {
181
184
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
185
  }
183
186
 
187
+ export type ShadowLevel = 'none' | 'sm' | 'md' | 'lg' | 'xl';
188
+
189
+ const SHADOWS: Record<Exclude<ShadowLevel, 'none'>, [y: number, blur: number, opacity: number]> = {
190
+ sm: [1, 3, 0.08],
191
+ md: [4, 14, 0.1],
192
+ lg: [10, 30, 0.14],
193
+ xl: [18, 48, 0.2],
194
+ };
195
+
196
+ /**
197
+ * A shadow as `boxShadow`, which React Native (0.76+) draws identically on iOS, Android and web —
198
+ * unlike `elevation`, whose Android shadow looks nothing like the iOS one. Darker in dark mode so
199
+ * it still reads on near-black. `color` is a #rrggbb tint (default black).
200
+ */
201
+ export function shadowFor(level: ShadowLevel, dark: boolean, color = '#000000'): ViewStyle {
202
+ if (level === 'none') return {};
203
+ const [y, blur, opacity] = SHADOWS[level];
204
+ const a = Math.min(0.6, dark ? opacity * 2.8 : opacity);
205
+ const full = color.length === 4 ? '#' + [...color.slice(1)].map((c) => c + c).join('') : color;
206
+ const n = parseInt(full.slice(1, 7), 16);
207
+ const [r, g, b] = Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
208
+ return { boxShadow: `0px ${y}px ${blur}px rgba(${r},${g},${b},${+a.toFixed(3)})` };
209
+ }
210
+
211
+ /**
212
+ * Screen size classes, shared by every component so phones, foldables and tablets all get the
213
+ * right layout without any work from you. `compact` — phones (any orientation); `medium` — open
214
+ * foldables (Galaxy Z Fold, Pixel Fold), Surface Duo and small tablets; `expanded` — iPad and
215
+ * larger. Decided by the screen's short side, so a phone on its side stays a phone.
216
+ */
217
+ export type SizeClass = 'compact' | 'medium' | 'expanded';
218
+
219
+ export const breakpoints = { medium: 600, expanded: 840 };
220
+
221
+ /** Widest a sheet / picker / popover card and a screen's content column get on big screens. */
222
+ export const layoutLimits = { sheet: 560, content: 720, gutter: 24 };
223
+
224
+ export function sizeClassOf(shortSide: number): SizeClass {
225
+ if (!(shortSide >= breakpoints.medium)) return 'compact';
226
+ return shortSide >= breakpoints.expanded ? 'expanded' : 'medium';
227
+ }
228
+
229
+ export interface Layout {
230
+ width: number;
231
+ height: number;
232
+ size: SizeClass;
233
+ /** Foldable, tablet or desktop — anything bigger than a phone. */
234
+ wide: boolean;
235
+ landscape: boolean;
236
+ fontScale: number;
237
+ /** Width for a bottom sheet, picker or action sheet: the full screen on a portrait phone, a centred card on anything wider. */
238
+ sheetWidth: number;
239
+ /** Width for a screen's main column (forms, feeds, blocks): full on phones, a readable centred column on tablets. */
240
+ contentWidth: number;
241
+ /** Grid columns that fit the current width: 1 phone, 2 foldable / landscape phone, 3 tablet. */
242
+ columns: number;
243
+ /** A value per size class; a missing one falls back to the next smaller: `pick({ compact: 16, expanded: 24 })`. */
244
+ pick: <T>(values: { compact: T; medium?: T; expanded?: T }) => T;
245
+ }
246
+
247
+ const finite = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0);
248
+
249
+ /** The layout for a window of `width` × `height` points (the pure part of useLayout). */
250
+ export function layoutFor(rawWidth: number, rawHeight: number, fontScale = 1): Layout {
251
+ const width = finite(rawWidth);
252
+ const height = finite(rawHeight);
253
+ const size = sizeClassOf(Math.min(width, height || width));
254
+ const roomy = width >= breakpoints.medium;
255
+ const { sheet, content, gutter } = layoutLimits;
256
+ return {
257
+ width,
258
+ height,
259
+ size,
260
+ wide: size !== 'compact',
261
+ landscape: width > height,
262
+ fontScale,
263
+ sheetWidth: roomy ? Math.min(sheet, width - gutter * 2) : width,
264
+ contentWidth: roomy ? Math.min(content, width - gutter * 2) : width,
265
+ columns: width >= breakpoints.expanded + 160 ? 3 : roomy ? 2 : 1,
266
+ pick: (values) =>
267
+ (size === 'expanded' ? values.expanded ?? values.medium : size === 'medium' ? values.medium : undefined) ??
268
+ values.compact,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The current screen's layout; re-renders on rotation, split view, Stage Manager and fold / unfold.
274
+ * Components use it so they look right on every device; use it in your own screens the same way:
275
+ * `const { contentWidth } = useLayout()` → `<View style={{ width: '100%', maxWidth: contentWidth, alignSelf: 'center' }}>`.
276
+ */
277
+ export function useLayout(): Layout {
278
+ const { width, height, fontScale } = useWindowDimensions();
279
+ return useMemo(() => layoutFor(width, height, fontScale), [width, height, fontScale]);
280
+ }
281
+
184
282
  /**
185
283
  * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
284
  * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react';
2
- import { Platform, useColorScheme, type TextStyle } from 'react-native';
2
+ import { Platform, useColorScheme, useWindowDimensions, type TextStyle, type ViewStyle } from 'react-native';
3
3
 
4
4
  /**
5
5
  * ExpoKit design tokens. Every component reads them through useKit(), so one
@@ -8,10 +8,10 @@ import { Platform, useColorScheme, type TextStyle } from 'react-native';
8
8
  */
9
9
 
10
10
  /**
11
- * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule).
12
- * The CLI won't keep an edited kit.tsx older than the component being added needs.
11
+ * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule,
12
+ * 3: useLayout / shadow). The CLI won't keep an edited kit.tsx older than the component being added needs.
13
13
  */
14
- export const KIT_VERSION = 2;
14
+ export const KIT_VERSION = 3;
15
15
 
16
16
  export type ColorScheme = 'light' | 'dark';
17
17
 
@@ -58,6 +58,8 @@ export interface Theme {
58
58
  };
59
59
  /** 4pt spacing scale: space(4) === 16. */
60
60
  space: (n: number) => number;
61
+ /** The same soft shadow on iOS, Android and web: `style={[styles.card, shadow('md')]}`. */
62
+ shadow: (level: ShadowLevel, color?: string) => ViewStyle;
61
63
  font: { family?: string; mono: string };
62
64
  text: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl', TextStyle>;
63
65
  }
@@ -134,6 +136,7 @@ function build(scheme: ColorScheme, overrides?: ThemeOverrides): Theme {
134
136
  capsule: (h) => Math.round((h / 2) * Math.min(1, r / 12)),
135
137
  },
136
138
  space: (n) => n * 4,
139
+ shadow: (level, color) => shadowFor(level, scheme === 'dark', color),
137
140
  font: {
138
141
  family: overrides?.fontFamily,
139
142
  mono: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' })!,
@@ -181,6 +184,101 @@ export function useKit(): Theme {
181
184
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
185
  }
183
186
 
187
+ export type ShadowLevel = 'none' | 'sm' | 'md' | 'lg' | 'xl';
188
+
189
+ const SHADOWS: Record<Exclude<ShadowLevel, 'none'>, [y: number, blur: number, opacity: number]> = {
190
+ sm: [1, 3, 0.08],
191
+ md: [4, 14, 0.1],
192
+ lg: [10, 30, 0.14],
193
+ xl: [18, 48, 0.2],
194
+ };
195
+
196
+ /**
197
+ * A shadow as `boxShadow`, which React Native (0.76+) draws identically on iOS, Android and web —
198
+ * unlike `elevation`, whose Android shadow looks nothing like the iOS one. Darker in dark mode so
199
+ * it still reads on near-black. `color` is a #rrggbb tint (default black).
200
+ */
201
+ export function shadowFor(level: ShadowLevel, dark: boolean, color = '#000000'): ViewStyle {
202
+ if (level === 'none') return {};
203
+ const [y, blur, opacity] = SHADOWS[level];
204
+ const a = Math.min(0.6, dark ? opacity * 2.8 : opacity);
205
+ const full = color.length === 4 ? '#' + [...color.slice(1)].map((c) => c + c).join('') : color;
206
+ const n = parseInt(full.slice(1, 7), 16);
207
+ const [r, g, b] = Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
208
+ return { boxShadow: `0px ${y}px ${blur}px rgba(${r},${g},${b},${+a.toFixed(3)})` };
209
+ }
210
+
211
+ /**
212
+ * Screen size classes, shared by every component so phones, foldables and tablets all get the
213
+ * right layout without any work from you. `compact` — phones (any orientation); `medium` — open
214
+ * foldables (Galaxy Z Fold, Pixel Fold), Surface Duo and small tablets; `expanded` — iPad and
215
+ * larger. Decided by the screen's short side, so a phone on its side stays a phone.
216
+ */
217
+ export type SizeClass = 'compact' | 'medium' | 'expanded';
218
+
219
+ export const breakpoints = { medium: 600, expanded: 840 };
220
+
221
+ /** Widest a sheet / picker / popover card and a screen's content column get on big screens. */
222
+ export const layoutLimits = { sheet: 560, content: 720, gutter: 24 };
223
+
224
+ export function sizeClassOf(shortSide: number): SizeClass {
225
+ if (!(shortSide >= breakpoints.medium)) return 'compact';
226
+ return shortSide >= breakpoints.expanded ? 'expanded' : 'medium';
227
+ }
228
+
229
+ export interface Layout {
230
+ width: number;
231
+ height: number;
232
+ size: SizeClass;
233
+ /** Foldable, tablet or desktop — anything bigger than a phone. */
234
+ wide: boolean;
235
+ landscape: boolean;
236
+ fontScale: number;
237
+ /** Width for a bottom sheet, picker or action sheet: the full screen on a portrait phone, a centred card on anything wider. */
238
+ sheetWidth: number;
239
+ /** Width for a screen's main column (forms, feeds, blocks): full on phones, a readable centred column on tablets. */
240
+ contentWidth: number;
241
+ /** Grid columns that fit the current width: 1 phone, 2 foldable / landscape phone, 3 tablet. */
242
+ columns: number;
243
+ /** A value per size class; a missing one falls back to the next smaller: `pick({ compact: 16, expanded: 24 })`. */
244
+ pick: <T>(values: { compact: T; medium?: T; expanded?: T }) => T;
245
+ }
246
+
247
+ const finite = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0);
248
+
249
+ /** The layout for a window of `width` × `height` points (the pure part of useLayout). */
250
+ export function layoutFor(rawWidth: number, rawHeight: number, fontScale = 1): Layout {
251
+ const width = finite(rawWidth);
252
+ const height = finite(rawHeight);
253
+ const size = sizeClassOf(Math.min(width, height || width));
254
+ const roomy = width >= breakpoints.medium;
255
+ const { sheet, content, gutter } = layoutLimits;
256
+ return {
257
+ width,
258
+ height,
259
+ size,
260
+ wide: size !== 'compact',
261
+ landscape: width > height,
262
+ fontScale,
263
+ sheetWidth: roomy ? Math.min(sheet, width - gutter * 2) : width,
264
+ contentWidth: roomy ? Math.min(content, width - gutter * 2) : width,
265
+ columns: width >= breakpoints.expanded + 160 ? 3 : roomy ? 2 : 1,
266
+ pick: (values) =>
267
+ (size === 'expanded' ? values.expanded ?? values.medium : size === 'medium' ? values.medium : undefined) ??
268
+ values.compact,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The current screen's layout; re-renders on rotation, split view, Stage Manager and fold / unfold.
274
+ * Components use it so they look right on every device; use it in your own screens the same way:
275
+ * `const { contentWidth } = useLayout()` → `<View style={{ width: '100%', maxWidth: contentWidth, alignSelf: 'center' }}>`.
276
+ */
277
+ export function useLayout(): Layout {
278
+ const { width, height, fontScale } = useWindowDimensions();
279
+ return useMemo(() => layoutFor(width, height, fontScale), [width, height, fontScale]);
280
+ }
281
+
184
282
  /**
185
283
  * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
284
  * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
@@ -296,6 +296,6 @@ const styles = StyleSheet.create({
296
296
  chip: { flexDirection: 'row', alignItems: 'center', borderWidth: 1 },
297
297
  checkWrap: { overflow: 'hidden', flexDirection: 'row', alignItems: 'center' },
298
298
  icon: { marginRight: 6 },
299
- label: { fontWeight: '400', letterSpacing: -0.1 },
300
- count: { marginLeft: 6, fontWeight: '400', fontVariant: ['tabular-nums'], opacity: 0.85 },
299
+ label: { fontWeight: '400', letterSpacing: -0.1, includeFontPadding: false, textAlignVertical: 'center' },
300
+ count: { marginLeft: 6, fontWeight: '400', fontVariant: ['tabular-nums'], opacity: 0.85, includeFontPadding: false, textAlignVertical: 'center' },
301
301
  });
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react';
2
- import { Platform, useColorScheme, type TextStyle } from 'react-native';
2
+ import { Platform, useColorScheme, useWindowDimensions, type TextStyle, type ViewStyle } from 'react-native';
3
3
 
4
4
  /**
5
5
  * ExpoKit design tokens. Every component reads them through useKit(), so one
@@ -8,10 +8,10 @@ import { Platform, useColorScheme, type TextStyle } from 'react-native';
8
8
  */
9
9
 
10
10
  /**
11
- * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule).
12
- * The CLI won't keep an edited kit.tsx older than the component being added needs.
11
+ * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule,
12
+ * 3: useLayout / shadow). The CLI won't keep an edited kit.tsx older than the component being added needs.
13
13
  */
14
- export const KIT_VERSION = 2;
14
+ export const KIT_VERSION = 3;
15
15
 
16
16
  export type ColorScheme = 'light' | 'dark';
17
17
 
@@ -58,6 +58,8 @@ export interface Theme {
58
58
  };
59
59
  /** 4pt spacing scale: space(4) === 16. */
60
60
  space: (n: number) => number;
61
+ /** The same soft shadow on iOS, Android and web: `style={[styles.card, shadow('md')]}`. */
62
+ shadow: (level: ShadowLevel, color?: string) => ViewStyle;
61
63
  font: { family?: string; mono: string };
62
64
  text: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl', TextStyle>;
63
65
  }
@@ -134,6 +136,7 @@ function build(scheme: ColorScheme, overrides?: ThemeOverrides): Theme {
134
136
  capsule: (h) => Math.round((h / 2) * Math.min(1, r / 12)),
135
137
  },
136
138
  space: (n) => n * 4,
139
+ shadow: (level, color) => shadowFor(level, scheme === 'dark', color),
137
140
  font: {
138
141
  family: overrides?.fontFamily,
139
142
  mono: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' })!,
@@ -181,6 +184,101 @@ export function useKit(): Theme {
181
184
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
185
  }
183
186
 
187
+ export type ShadowLevel = 'none' | 'sm' | 'md' | 'lg' | 'xl';
188
+
189
+ const SHADOWS: Record<Exclude<ShadowLevel, 'none'>, [y: number, blur: number, opacity: number]> = {
190
+ sm: [1, 3, 0.08],
191
+ md: [4, 14, 0.1],
192
+ lg: [10, 30, 0.14],
193
+ xl: [18, 48, 0.2],
194
+ };
195
+
196
+ /**
197
+ * A shadow as `boxShadow`, which React Native (0.76+) draws identically on iOS, Android and web —
198
+ * unlike `elevation`, whose Android shadow looks nothing like the iOS one. Darker in dark mode so
199
+ * it still reads on near-black. `color` is a #rrggbb tint (default black).
200
+ */
201
+ export function shadowFor(level: ShadowLevel, dark: boolean, color = '#000000'): ViewStyle {
202
+ if (level === 'none') return {};
203
+ const [y, blur, opacity] = SHADOWS[level];
204
+ const a = Math.min(0.6, dark ? opacity * 2.8 : opacity);
205
+ const full = color.length === 4 ? '#' + [...color.slice(1)].map((c) => c + c).join('') : color;
206
+ const n = parseInt(full.slice(1, 7), 16);
207
+ const [r, g, b] = Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
208
+ return { boxShadow: `0px ${y}px ${blur}px rgba(${r},${g},${b},${+a.toFixed(3)})` };
209
+ }
210
+
211
+ /**
212
+ * Screen size classes, shared by every component so phones, foldables and tablets all get the
213
+ * right layout without any work from you. `compact` — phones (any orientation); `medium` — open
214
+ * foldables (Galaxy Z Fold, Pixel Fold), Surface Duo and small tablets; `expanded` — iPad and
215
+ * larger. Decided by the screen's short side, so a phone on its side stays a phone.
216
+ */
217
+ export type SizeClass = 'compact' | 'medium' | 'expanded';
218
+
219
+ export const breakpoints = { medium: 600, expanded: 840 };
220
+
221
+ /** Widest a sheet / picker / popover card and a screen's content column get on big screens. */
222
+ export const layoutLimits = { sheet: 560, content: 720, gutter: 24 };
223
+
224
+ export function sizeClassOf(shortSide: number): SizeClass {
225
+ if (!(shortSide >= breakpoints.medium)) return 'compact';
226
+ return shortSide >= breakpoints.expanded ? 'expanded' : 'medium';
227
+ }
228
+
229
+ export interface Layout {
230
+ width: number;
231
+ height: number;
232
+ size: SizeClass;
233
+ /** Foldable, tablet or desktop — anything bigger than a phone. */
234
+ wide: boolean;
235
+ landscape: boolean;
236
+ fontScale: number;
237
+ /** Width for a bottom sheet, picker or action sheet: the full screen on a portrait phone, a centred card on anything wider. */
238
+ sheetWidth: number;
239
+ /** Width for a screen's main column (forms, feeds, blocks): full on phones, a readable centred column on tablets. */
240
+ contentWidth: number;
241
+ /** Grid columns that fit the current width: 1 phone, 2 foldable / landscape phone, 3 tablet. */
242
+ columns: number;
243
+ /** A value per size class; a missing one falls back to the next smaller: `pick({ compact: 16, expanded: 24 })`. */
244
+ pick: <T>(values: { compact: T; medium?: T; expanded?: T }) => T;
245
+ }
246
+
247
+ const finite = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0);
248
+
249
+ /** The layout for a window of `width` × `height` points (the pure part of useLayout). */
250
+ export function layoutFor(rawWidth: number, rawHeight: number, fontScale = 1): Layout {
251
+ const width = finite(rawWidth);
252
+ const height = finite(rawHeight);
253
+ const size = sizeClassOf(Math.min(width, height || width));
254
+ const roomy = width >= breakpoints.medium;
255
+ const { sheet, content, gutter } = layoutLimits;
256
+ return {
257
+ width,
258
+ height,
259
+ size,
260
+ wide: size !== 'compact',
261
+ landscape: width > height,
262
+ fontScale,
263
+ sheetWidth: roomy ? Math.min(sheet, width - gutter * 2) : width,
264
+ contentWidth: roomy ? Math.min(content, width - gutter * 2) : width,
265
+ columns: width >= breakpoints.expanded + 160 ? 3 : roomy ? 2 : 1,
266
+ pick: (values) =>
267
+ (size === 'expanded' ? values.expanded ?? values.medium : size === 'medium' ? values.medium : undefined) ??
268
+ values.compact,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The current screen's layout; re-renders on rotation, split view, Stage Manager and fold / unfold.
274
+ * Components use it so they look right on every device; use it in your own screens the same way:
275
+ * `const { contentWidth } = useLayout()` → `<View style={{ width: '100%', maxWidth: contentWidth, alignSelf: 'center' }}>`.
276
+ */
277
+ export function useLayout(): Layout {
278
+ const { width, height, fontScale } = useWindowDimensions();
279
+ return useMemo(() => layoutFor(width, height, fontScale), [width, height, fontScale]);
280
+ }
281
+
184
282
  /**
185
283
  * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
284
  * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, useMemo } from 'react';
2
- import { Platform, useColorScheme, type TextStyle } from 'react-native';
2
+ import { Platform, useColorScheme, useWindowDimensions, type TextStyle, type ViewStyle } from 'react-native';
3
3
 
4
4
  /**
5
5
  * ExpoKit design tokens. Every component reads them through useKit(), so one
@@ -8,10 +8,10 @@ import { Platform, useColorScheme, type TextStyle } from 'react-native';
8
8
  */
9
9
 
10
10
  /**
11
- * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule).
12
- * The CLI won't keep an edited kit.tsx older than the component being added needs.
11
+ * Bumped whenever components start using a new kit API (2: radius.scale / radius.capsule,
12
+ * 3: useLayout / shadow). The CLI won't keep an edited kit.tsx older than the component being added needs.
13
13
  */
14
- export const KIT_VERSION = 2;
14
+ export const KIT_VERSION = 3;
15
15
 
16
16
  export type ColorScheme = 'light' | 'dark';
17
17
 
@@ -58,6 +58,8 @@ export interface Theme {
58
58
  };
59
59
  /** 4pt spacing scale: space(4) === 16. */
60
60
  space: (n: number) => number;
61
+ /** The same soft shadow on iOS, Android and web: `style={[styles.card, shadow('md')]}`. */
62
+ shadow: (level: ShadowLevel, color?: string) => ViewStyle;
61
63
  font: { family?: string; mono: string };
62
64
  text: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl', TextStyle>;
63
65
  }
@@ -134,6 +136,7 @@ function build(scheme: ColorScheme, overrides?: ThemeOverrides): Theme {
134
136
  capsule: (h) => Math.round((h / 2) * Math.min(1, r / 12)),
135
137
  },
136
138
  space: (n) => n * 4,
139
+ shadow: (level, color) => shadowFor(level, scheme === 'dark', color),
137
140
  font: {
138
141
  family: overrides?.fontFamily,
139
142
  mono: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' })!,
@@ -181,6 +184,101 @@ export function useKit(): Theme {
181
184
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
185
  }
183
186
 
187
+ export type ShadowLevel = 'none' | 'sm' | 'md' | 'lg' | 'xl';
188
+
189
+ const SHADOWS: Record<Exclude<ShadowLevel, 'none'>, [y: number, blur: number, opacity: number]> = {
190
+ sm: [1, 3, 0.08],
191
+ md: [4, 14, 0.1],
192
+ lg: [10, 30, 0.14],
193
+ xl: [18, 48, 0.2],
194
+ };
195
+
196
+ /**
197
+ * A shadow as `boxShadow`, which React Native (0.76+) draws identically on iOS, Android and web —
198
+ * unlike `elevation`, whose Android shadow looks nothing like the iOS one. Darker in dark mode so
199
+ * it still reads on near-black. `color` is a #rrggbb tint (default black).
200
+ */
201
+ export function shadowFor(level: ShadowLevel, dark: boolean, color = '#000000'): ViewStyle {
202
+ if (level === 'none') return {};
203
+ const [y, blur, opacity] = SHADOWS[level];
204
+ const a = Math.min(0.6, dark ? opacity * 2.8 : opacity);
205
+ const full = color.length === 4 ? '#' + [...color.slice(1)].map((c) => c + c).join('') : color;
206
+ const n = parseInt(full.slice(1, 7), 16);
207
+ const [r, g, b] = Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255];
208
+ return { boxShadow: `0px ${y}px ${blur}px rgba(${r},${g},${b},${+a.toFixed(3)})` };
209
+ }
210
+
211
+ /**
212
+ * Screen size classes, shared by every component so phones, foldables and tablets all get the
213
+ * right layout without any work from you. `compact` — phones (any orientation); `medium` — open
214
+ * foldables (Galaxy Z Fold, Pixel Fold), Surface Duo and small tablets; `expanded` — iPad and
215
+ * larger. Decided by the screen's short side, so a phone on its side stays a phone.
216
+ */
217
+ export type SizeClass = 'compact' | 'medium' | 'expanded';
218
+
219
+ export const breakpoints = { medium: 600, expanded: 840 };
220
+
221
+ /** Widest a sheet / picker / popover card and a screen's content column get on big screens. */
222
+ export const layoutLimits = { sheet: 560, content: 720, gutter: 24 };
223
+
224
+ export function sizeClassOf(shortSide: number): SizeClass {
225
+ if (!(shortSide >= breakpoints.medium)) return 'compact';
226
+ return shortSide >= breakpoints.expanded ? 'expanded' : 'medium';
227
+ }
228
+
229
+ export interface Layout {
230
+ width: number;
231
+ height: number;
232
+ size: SizeClass;
233
+ /** Foldable, tablet or desktop — anything bigger than a phone. */
234
+ wide: boolean;
235
+ landscape: boolean;
236
+ fontScale: number;
237
+ /** Width for a bottom sheet, picker or action sheet: the full screen on a portrait phone, a centred card on anything wider. */
238
+ sheetWidth: number;
239
+ /** Width for a screen's main column (forms, feeds, blocks): full on phones, a readable centred column on tablets. */
240
+ contentWidth: number;
241
+ /** Grid columns that fit the current width: 1 phone, 2 foldable / landscape phone, 3 tablet. */
242
+ columns: number;
243
+ /** A value per size class; a missing one falls back to the next smaller: `pick({ compact: 16, expanded: 24 })`. */
244
+ pick: <T>(values: { compact: T; medium?: T; expanded?: T }) => T;
245
+ }
246
+
247
+ const finite = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0);
248
+
249
+ /** The layout for a window of `width` × `height` points (the pure part of useLayout). */
250
+ export function layoutFor(rawWidth: number, rawHeight: number, fontScale = 1): Layout {
251
+ const width = finite(rawWidth);
252
+ const height = finite(rawHeight);
253
+ const size = sizeClassOf(Math.min(width, height || width));
254
+ const roomy = width >= breakpoints.medium;
255
+ const { sheet, content, gutter } = layoutLimits;
256
+ return {
257
+ width,
258
+ height,
259
+ size,
260
+ wide: size !== 'compact',
261
+ landscape: width > height,
262
+ fontScale,
263
+ sheetWidth: roomy ? Math.min(sheet, width - gutter * 2) : width,
264
+ contentWidth: roomy ? Math.min(content, width - gutter * 2) : width,
265
+ columns: width >= breakpoints.expanded + 160 ? 3 : roomy ? 2 : 1,
266
+ pick: (values) =>
267
+ (size === 'expanded' ? values.expanded ?? values.medium : size === 'medium' ? values.medium : undefined) ??
268
+ values.compact,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The current screen's layout; re-renders on rotation, split view, Stage Manager and fold / unfold.
274
+ * Components use it so they look right on every device; use it in your own screens the same way:
275
+ * `const { contentWidth } = useLayout()` → `<View style={{ width: '100%', maxWidth: contentWidth, alignSelf: 'center' }}>`.
276
+ */
277
+ export function useLayout(): Layout {
278
+ const { width, height, fontScale } = useWindowDimensions();
279
+ return useMemo(() => layoutFor(width, height, fontScale), [width, height, fontScale]);
280
+ }
281
+
184
282
  /**
185
283
  * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
284
  * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
@@ -234,7 +234,7 @@ function CodeChip({ code, color, ink, fontSize, onCopy, labels, reduce }: { code
234
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]} />
235
235
  </Svg>
236
236
  ) : null}
237
- <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize, fontWeight: '800', letterSpacing: 1, flexShrink: 1 }, ff]}>
237
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: ink, fontSize, fontWeight: '800', letterSpacing: 1, flexShrink: 1, includeFontPadding: false, textAlignVertical: 'center' }, ff]}>
238
238
  {code}
239
239
  </Text>
240
240
  {onCopy ? (
@@ -431,7 +431,7 @@ export function Coupon({
431
431
  left !== undefined && left > 0 && !spent ? (
432
432
  <View style={[styles.timer, { backgroundColor: timerBg }]}>
433
433
  <Icon d={ICON.clock} size={11} color={timerInk} />
434
- <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: timerInk, fontSize: 10.5, fontWeight: '800', fontVariant: ['tabular-nums'] }, ff]}>
434
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: timerInk, fontSize: 10.5, fontWeight: '800', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlignVertical: 'center' }, ff]}>
435
435
  {labels.endsIn} {formatCountdown(left)}
436
436
  </Text>
437
437
  </View>
@@ -521,7 +521,7 @@ export function Coupon({
521
521
  ]}
522
522
  >
523
523
  {applied || locked ? <Icon d={applied ? ICON.check : ICON.lock} size={13} color={applied ? '#ffffff' : bodySoft} /> : null}
524
- <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: applied ? '#ffffff' : locked || spent ? bodySoft : readableOn(accent), fontSize: s.chip - 0.5, fontWeight: '800' }, ff]}>
524
+ <Text numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[{ color: applied ? '#ffffff' : locked || spent ? bodySoft : readableOn(accent), fontSize: s.chip - 0.5, fontWeight: '800', includeFontPadding: false, textAlignVertical: 'center' }, ff]}>
525
525
  {applied ? labels.applied : locked ? labels.locked : labels.apply}
526
526
  </Text>
527
527
  </Pressable>
@@ -679,9 +679,8 @@ export function Coupon({
679
679
  <View style={{ transform: [{ scale: pressed && !reduce ? 0.985 : 1 }] }}>
680
680
  {/* The plate waits for the painted outline (no black flash on mount), and a dimmed (used /
681
681
  expired) coupon is see-through, so it gets none. */}
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 }]} />}
683
- {/* Elevation above the shadow plate keeps the coupon on top on Android (no background → no shadow of its own). */}
684
- <View style={{ elevation: 7 }}>{card}</View>
682
+ {spent || !measured ? null : <View pointerEvents="none" style={[styles.shadow, { top: inset, bottom: inset - 4, left: inset, right: inset, borderRadius: v.r, boxShadow: `0px 8px 28px rgba(0,0,0,${dark ? 0.5 : 0.2})` }]} />}
683
+ <View>{card}</View>
685
684
  </View>
686
685
  )}
687
686
  </Pressable>
@@ -717,9 +716,5 @@ const styles = StyleSheet.create({
717
716
  shadow: {
718
717
  position: 'absolute',
719
718
  backgroundColor: '#000',
720
- shadowColor: '#000',
721
- shadowRadius: 14,
722
- shadowOffset: { width: 0, height: 8 },
723
- elevation: 6,
724
719
  },
725
720
  });
@@ -149,9 +149,7 @@ export function FlipCard({
149
149
  ) : null}
150
150
  {/* Faces live in their own (non-collapsing) layer: iOS intersects sibling layers in 3D, so a
151
151
  turning face next to the flat shadow plate would be sliced by it. */}
152
- {/* On Android the wrapper carries the elevation that keeps the faces above the plate; the faces
153
- get none, since a face turned in 3D would cast a warped shadow wedge below the card. */}
154
- <View collapsable={false} style={{ elevation: shadow ? 23 : 0 }}>
152
+ <View collapsable={false}>
155
153
  {/* The hidden face stays mounted (for the turn) but is hidden from screen readers. */}
156
154
  <Animated.View
157
155
  style={[styles.face, face(frontTurn, frontOpacity)]}
@@ -214,11 +212,7 @@ const styles = StyleSheet.create({
214
212
  right: 0,
215
213
  bottom: 0,
216
214
  backgroundColor: '#000',
217
- shadowColor: '#000',
218
- shadowOpacity: 0.4,
219
- shadowRadius: 20,
220
- shadowOffset: { width: 0, height: 14 },
221
- // High enough that Android's shadow spreads past the inset plate (iOS uses the shadow* props).
222
- elevation: 22,
215
+ // boxShadow, not shadow* / elevation: the same soft shadow on iOS, Android and web.
216
+ boxShadow: '0px 14px 40px rgba(0,0,0,0.4)',
223
217
  },
224
218
  });