@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
@@ -469,9 +469,8 @@ export function TiltCard({
469
469
  ) : null}
470
470
  {/* The card sits in its own (non-collapsing) layer: iOS intersects sibling layers in 3D, so a
471
471
  tilted card next to the flat shadow plate would be sliced by it along a diagonal. */}
472
- {/* Android draws siblings by elevation, so the wrapper (not the card inside it) carries the
473
- elevation that keeps it above the plate; with no background it casts no shadow itself. */}
474
- <View collapsable={false} style={{ elevation: shadow ? 15 : 0 }}>
472
+ {/* The plate's shadow is a boxShadow (no elevation), so on Android too the card simply paints above it. */}
473
+ <View collapsable={false}>
475
474
  <Animated.View
476
475
  ref={viewRef}
477
476
  {...responder.panHandlers}
@@ -483,10 +482,8 @@ export function TiltCard({
483
482
  onAccessibilityAction={(e) => {
484
483
  if (e.nativeEvent.actionName === 'activate') onPressRef.current?.();
485
484
  }}
486
- // elevation (no background → no extra shadow) keeps the card above its shadow plate on Android.
487
485
  style={{
488
486
  borderRadius: radius,
489
- elevation: shadow ? 15 : 0,
490
487
  transform: [{ perspective }, { rotateX }, { rotateY }, { scale }],
491
488
  }}
492
489
  >
@@ -665,11 +662,8 @@ const styles = StyleSheet.create({
665
662
  right: 0,
666
663
  bottom: 0,
667
664
  backgroundColor: '#000',
668
- shadowColor: '#000',
669
- shadowOpacity: 0.45,
670
- shadowRadius: 24,
671
- shadowOffset: { width: 0, height: 16 },
672
- elevation: 14,
665
+ // boxShadow draws the same soft shadow on iOS, Android and web.
666
+ boxShadow: '0px 16px 48px rgba(0,0,0,0.45)',
673
667
  },
674
668
  clip: { overflow: 'hidden' },
675
669
  window: { position: 'absolute', overflow: 'hidden' },
@@ -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.
@@ -839,7 +839,7 @@ const styles = StyleSheet.create({
839
839
  navs: { flexDirection: 'row', alignItems: 'center', gap: 6 },
840
840
  nav: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
841
841
  todayBtn: { minHeight: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
842
- todayText: { fontSize: 13, fontWeight: '400' },
842
+ todayText: { fontSize: 13, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
843
843
  weekRow: { flexDirection: 'row', marginBottom: 6 },
844
844
  weekday: { flex: 1, textAlign: 'center', fontSize: 13, fontWeight: '400', letterSpacing: 0.2 },
845
845
  gridClip: { overflow: 'hidden' },
@@ -858,7 +858,7 @@ const styles = StyleSheet.create({
858
858
  alignSelf: 'center',
859
859
  },
860
860
  fill: { borderWidth: 0 },
861
- day: { fontSize: 15, fontWeight: '400', fontVariant: ['tabular-nums'] },
861
+ day: { fontSize: 15, fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlignVertical: 'center' },
862
862
  dayStrong: { fontWeight: '400' },
863
863
  outside: { opacity: 0.4 },
864
864
  disabledText: { opacity: 0.35 },
@@ -867,5 +867,5 @@ const styles = StyleSheet.create({
867
867
  pickGrid: { flexDirection: 'row', flexWrap: 'wrap', alignContent: 'center', paddingTop: 4 },
868
868
  pickCell: { width: '33.333%', height: 56, alignItems: 'center', justifyContent: 'center' },
869
869
  pickInner: { minWidth: 76, minHeight: 40, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5 },
870
- pickText: { fontSize: 15, fontWeight: '400' },
870
+ pickText: { fontSize: 15, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
871
871
  });
@@ -26,7 +26,7 @@ import {
26
26
  type CalendarProps,
27
27
  type DateRange,
28
28
  } from '@/components/ui/calendar';
29
- import { alpha, motion, readableOn, useKit } from '@/lib/kit';
29
+ import { alpha, motion, readableOn, useKit, useLayout } from '@/lib/kit';
30
30
 
31
31
  export type { DateRange } from '@/components/ui/calendar';
32
32
  export type DatePickerSize = 'sm' | 'md' | 'lg';
@@ -244,6 +244,7 @@ export function DatePicker(props: DatePickerProps) {
244
244
  typeof userField.borderRadius === 'number' ? userField.borderRadius : size === 'sm' ? radius.md - 2 : size === 'lg' ? radius.lg : radius.md;
245
245
  const restIcon = iconColor ?? colors.mutedForeground;
246
246
  const { width: winW, height: winH } = useWindowDimensions();
247
+ const layout = useLayout();
247
248
  const ranged = mode === 'range';
248
249
  const reduce = useReduceMotion();
249
250
  // The pan responder is built once; it reads Reduce Motion through this ref.
@@ -273,8 +274,8 @@ export function DatePicker(props: DatePickerProps) {
273
274
  const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
274
275
 
275
276
  const asSheet = presentation !== 'popover';
276
- // Centred dialog instead of a bottom sheet.
277
- const wide = presentation === 'dialog' || (presentation === 'auto' && winW >= 640);
277
+ // Centred dialog instead of a bottom sheet: on foldables, tablets and landscape phones.
278
+ const wide = presentation === 'dialog' || (presentation === 'auto' && (layout.wide || layout.landscape));
278
279
 
279
280
  useEffect(() => {
280
281
  if (reduce) {
@@ -661,7 +662,7 @@ export function DatePicker(props: DatePickerProps) {
661
662
  { backgroundColor: colors.popover, borderColor: colors.border, maxHeight: winH * 0.92 },
662
663
  wide
663
664
  ? { width: Math.min(420, winW - 48), borderRadius: radius.xl, paddingBottom: 14 }
664
- : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 30 },
665
+ : { width: layout.sheetWidth, alignSelf: 'center', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 30 },
665
666
  menuStyle,
666
667
  wide
667
668
  ? {
@@ -816,11 +817,8 @@ const styles = StyleSheet.create({
816
817
  centered: { justifyContent: 'center', alignItems: 'center' },
817
818
  sheet: {
818
819
  borderWidth: StyleSheet.hairlineWidth,
819
- shadowColor: '#000',
820
- shadowOpacity: 0.18,
821
- shadowRadius: 24,
822
- shadowOffset: { width: 0, height: -4 },
823
- elevation: 16,
820
+ // boxShadow, not shadow* / elevation: the same soft shadow on iOS, Android and web.
821
+ boxShadow: '0px -4px 48px rgba(0,0,0,0.18)',
824
822
  },
825
823
  sheetHeader: { alignItems: 'center', flexShrink: 0, paddingTop: 10, paddingBottom: 8, paddingHorizontal: 16 },
826
824
  grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
@@ -830,21 +828,17 @@ const styles = StyleSheet.create({
830
828
  calendarScroll: { flexGrow: 0, flexShrink: 1 },
831
829
  presets: { gap: 8, paddingHorizontal: 16, paddingVertical: 8 },
832
830
  preset: { minHeight: 34, paddingHorizontal: 14, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
833
- presetText: { fontSize: 13.5, fontWeight: '400' },
831
+ presetText: { fontSize: 13.5, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
834
832
  calendar: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 6 },
835
833
  footer: { flexDirection: 'row', flexShrink: 0, gap: 8, paddingTop: 12, paddingHorizontal: 16, borderTopWidth: StyleSheet.hairlineWidth },
836
834
  footBtn: { minHeight: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
837
835
  footMain: { flex: 1 },
838
- footText: { fontSize: 15, fontWeight: '400' },
836
+ footText: { fontSize: 15, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
839
837
  popover: {
840
838
  position: 'absolute',
841
839
  paddingTop: 10,
842
840
  paddingBottom: 10,
843
841
  borderWidth: StyleSheet.hairlineWidth,
844
- shadowColor: '#000',
845
- shadowOpacity: 0.16,
846
- shadowRadius: 20,
847
- shadowOffset: { width: 0, height: 10 },
848
- elevation: 14,
842
+ boxShadow: '0px 10px 40px rgba(0,0,0,0.16)',
849
843
  },
850
844
  });
@@ -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.
@@ -476,13 +476,13 @@ export function Dialog({
476
476
  maxWidth: ios ? 300 : 340,
477
477
  borderRadius: corner,
478
478
  backgroundColor: colors.popover,
479
- shadowOpacity: dark ? 0.55 : 0.18,
479
+ boxShadow: `0px 16px 64px rgba(0,0,0,${dark ? 0.55 : 0.18})`,
480
480
  },
481
481
  lifted,
482
482
  style,
483
483
  ]}
484
484
  >
485
- <View style={[styles.contact, { borderRadius: corner, backgroundColor: colors.popover, shadowOpacity: dark ? 0.3 : 0.06 }]}>
485
+ <View style={{ borderRadius: corner, backgroundColor: colors.popover, boxShadow: `0px 1px 6px rgba(0,0,0,${dark ? 0.3 : 0.06})` }}>
486
486
  <View
487
487
  onLayout={(e) => {
488
488
  const { width: w, height: h } = e.nativeEvent.layout;
@@ -734,14 +734,8 @@ function useReduceMotion() {
734
734
 
735
735
  const styles = StyleSheet.create({
736
736
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
737
- ambient: {
738
- width: '100%',
739
- shadowColor: '#000',
740
- shadowRadius: 32,
741
- shadowOffset: { width: 0, height: 16 },
742
- elevation: 18,
743
- },
744
- contact: { shadowColor: '#000', shadowRadius: 3, shadowOffset: { width: 0, height: 1 } },
737
+ // Shadows are boxShadow (set inline): the same on iOS, Android and web.
738
+ ambient: { width: '100%' },
745
739
  clip: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
746
740
  content: { paddingHorizontal: 22, paddingTop: 24 },
747
741
  contentIos: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 18 },
@@ -762,8 +756,8 @@ const styles = StyleSheet.create({
762
756
  flex: { flex: 1 },
763
757
  actions: { gap: 10, padding: 22, paddingTop: 22 },
764
758
  pill: { minHeight: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
765
- pillText: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.1 },
759
+ pillText: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.1, includeFontPadding: false, textAlignVertical: 'center' },
766
760
  iosActions: { borderTopWidth: StyleSheet.hairlineWidth },
767
761
  iosButton: { minHeight: 46, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },
768
- iosText: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3 },
762
+ iosText: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3, includeFontPadding: false, textAlignVertical: 'center' },
769
763
  });
@@ -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.
@@ -337,13 +337,6 @@ export function Drawer({
337
337
  styles.panel,
338
338
  side === 'left' ? { left: -EXTRA } : { right: -EXTRA },
339
339
  { width: W + EXTRA, opacity: panelOpacity, transform: [{ translateX: panelX }] },
340
- !push && {
341
- shadowColor: '#000',
342
- shadowOpacity: dark ? 0.45 : 0.12,
343
- shadowRadius: 28,
344
- shadowOffset: { width: -dir * 8, height: 0 },
345
- elevation: 24,
346
- },
347
340
  panelStyle,
348
341
  ]}
349
342
  >
@@ -353,10 +346,8 @@ export function Drawer({
353
346
  round,
354
347
  !push && {
355
348
  backgroundColor: surface,
356
- shadowColor: '#000',
357
- shadowOpacity: dark ? 0.3 : 0.06,
358
- shadowRadius: 4,
359
- shadowOffset: { width: -dir * 1, height: 0 },
349
+ // Ambient + contact shadow as boxShadow: the same on iOS, Android and web.
350
+ boxShadow: `${-dir * 8}px 0px 56px rgba(0,0,0,${dark ? 0.45 : 0.12}), ${-dir}px 0px 8px rgba(0,0,0,${dark ? 0.3 : 0.06})`,
360
351
  },
361
352
  ]}
362
353
  >
@@ -444,13 +435,6 @@ export function Drawer({
444
435
  style={[
445
436
  StyleSheet.absoluteFill,
446
437
  screenTransform && { transform: screenTransform },
447
- push &&
448
- Platform.OS === 'ios' && {
449
- shadowColor: '#000',
450
- shadowOpacity: dark ? 0.5 : 0.16,
451
- shadowRadius: 24,
452
- shadowOffset: { width: dir * 6, height: 8 },
453
- },
454
438
  ]}
455
439
  >
456
440
  <Animated.View
@@ -462,8 +446,8 @@ export function Drawer({
462
446
  borderWidth: StyleSheet.hairlineWidth,
463
447
  borderColor: screenEdge,
464
448
  overflow: 'hidden',
465
- elevation: push ? 16 : 0,
466
449
  },
450
+ push && { boxShadow: `${dir * 6}px 8px 48px rgba(0,0,0,${dark ? 0.5 : 0.16})` },
467
451
  ]}
468
452
  >
469
453
  {children}