@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
@@ -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.
@@ -17,7 +17,7 @@ import {
17
17
  } from 'react-native';
18
18
  import Svg, { Path } from 'react-native-svg';
19
19
 
20
- import { alpha, motion, readableOn, useKit } from '@/lib/kit';
20
+ import { alpha, motion, readableOn, useKit, useLayout } from '@/lib/kit';
21
21
 
22
22
  /**
23
23
  * `inset` — iOS: grouped rounded cards floating above the home indicator, with a separate Cancel card (default).
@@ -140,6 +140,8 @@ export function ActionSheet({
140
140
  const { colors, radius, dark, font } = useKit();
141
141
  const ff = font.family ? { fontFamily: font.family } : null;
142
142
  const { height: winH } = useWindowDimensions();
143
+ // Full width on portrait phones; a centred, width-capped card on foldables, tablets and landscape.
144
+ const { sheetWidth } = useLayout();
143
145
  const reduceMotion = useReduceMotion();
144
146
  const tint = accent ?? colors.accent;
145
147
  const [inner, setInner] = useState(defaultOpen);
@@ -320,7 +322,7 @@ export function ActionSheet({
320
322
  borderTopLeftRadius: radius.scale(28),
321
323
  borderTopRightRadius: radius.scale(28),
322
324
  borderColor: edge,
323
- shadowOpacity: dark ? 0.5 : 0.12,
325
+ boxShadow: `0px -6px 48px rgba(0,0,0,${dark ? 0.5 : 0.12})`,
324
326
  },
325
327
  ]}
326
328
  >
@@ -422,6 +424,7 @@ export function ActionSheet({
422
424
  }}
423
425
  style={[
424
426
  styles.sheet,
427
+ { maxWidth: sheetWidth },
425
428
  inset && { paddingHorizontal: 8, paddingBottom: 8 + bottomInset },
426
429
  {
427
430
  opacity: reduceMotion ? t : 1,
@@ -459,8 +462,8 @@ function Card({
459
462
  children: React.ReactNode;
460
463
  }) {
461
464
  return (
462
- <View style={[styles.cardAmbient, { borderRadius: corner, backgroundColor: bg, shadowOpacity: dark ? 0.5 : 0.14 }]}>
463
- <View style={[styles.cardContact, { borderRadius: corner, backgroundColor: bg, shadowOpacity: dark ? 0.3 : 0.06 }]}>
465
+ <View style={{ borderRadius: corner, backgroundColor: bg, boxShadow: `0px 10px 48px rgba(0,0,0,${dark ? 0.5 : 0.14})` }}>
466
+ <View style={{ borderRadius: corner, backgroundColor: bg, boxShadow: `0px 1px 4px rgba(0,0,0,${dark ? 0.3 : 0.06})` }}>
464
467
  <View style={{ borderRadius: corner, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth, borderColor: edge }}>
465
468
  {children}
466
469
  </View>
@@ -546,9 +549,7 @@ function useReduceMotion() {
546
549
 
547
550
  const styles = StyleSheet.create({
548
551
  host: { flex: 1, justifyContent: 'flex-end', alignItems: 'center' },
549
- sheet: { width: '100%', maxWidth: 480, gap: 8 },
550
- cardAmbient: { shadowColor: '#000', shadowRadius: 24, shadowOffset: { width: 0, height: 10 }, elevation: 10 },
551
- cardContact: { shadowColor: '#000', shadowRadius: 2, shadowOffset: { width: 0, height: 1 } },
552
+ sheet: { width: '100%', gap: 8 },
552
553
  head: { paddingHorizontal: 24, paddingTop: 15, paddingBottom: 14, alignItems: 'center', gap: 4 },
553
554
  headLeft: { paddingHorizontal: 20, paddingTop: 6, paddingBottom: 10, gap: 4 },
554
555
  title: { fontSize: 13.5, lineHeight: 18, fontWeight: '400', textAlign: 'center' },
@@ -566,10 +567,6 @@ const styles = StyleSheet.create({
566
567
  borderWidth: StyleSheet.hairlineWidth,
567
568
  borderBottomWidth: 0,
568
569
  paddingHorizontal: 8,
569
- shadowColor: '#000',
570
- shadowRadius: 24,
571
- shadowOffset: { width: 0, height: -6 },
572
- elevation: 16,
573
570
  },
574
571
  listRow: { minHeight: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, gap: 16 },
575
572
  listIcon: { width: 24, alignItems: 'center' },
@@ -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.
@@ -654,7 +654,7 @@ const styles = StyleSheet.create({
654
654
  desc: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
655
655
  actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
656
656
  pill: { alignItems: 'center', justifyContent: 'center' },
657
- pillText: { fontSize: 14, fontWeight: '400', lineHeight: 18 },
657
+ pillText: { fontSize: 14, fontWeight: '400', lineHeight: 18, includeFontPadding: false, textAlignVertical: 'center' },
658
658
  close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' },
659
659
  timer: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2 },
660
660
  });
@@ -121,7 +121,7 @@ export function Button({
121
121
  onPressOut,
122
122
  ...props
123
123
  }: ButtonProps) {
124
- const { colors, radius, dark, font } = useKit();
124
+ const { colors, radius, dark, font, shadow } = useKit();
125
125
  const ff = font.family ? { fontFamily: font.family } : null;
126
126
  const gid = 'btn' + useId().replace(/[^a-zA-Z0-9]/g, '');
127
127
  const reduce = useReduceMotion();
@@ -208,9 +208,9 @@ export function Button({
208
208
  opacity: isDisabled ? 0.5 : pressed && variant === 'link' ? 0.6 : 1,
209
209
  },
210
210
  pressed && quiet && variant !== 'link' && { backgroundColor: color ? alpha(tint, 0.1) : colors.muted },
211
- // Coloured glow under the button (iOS / web; Android shows plain elevation).
212
- variant === 'glow' && !isDisabled && [styles.glow, { shadowColor: tint }],
213
- variant === 'elevated' && styles.elevated,
211
+ // Coloured glow / soft lift as boxShadow: the same on iOS, Android and web.
212
+ variant === 'glow' && !isDisabled && { boxShadow: `0px 6px 28px ${alpha(tint, 0.45)}` },
213
+ variant === 'elevated' && shadow('md'),
214
214
  ]}
215
215
  {...props}
216
216
  >
@@ -285,19 +285,6 @@ const styles = StyleSheet.create({
285
285
  base: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
286
286
  row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
287
287
  block: { alignSelf: 'stretch' },
288
- label: { fontWeight: '400', letterSpacing: -0.1 },
288
+ label: { fontWeight: '400', letterSpacing: -0.1, includeFontPadding: false, textAlignVertical: 'center' },
289
289
  link: { textDecorationLine: 'underline' },
290
- glow: {
291
- shadowOpacity: 0.45,
292
- shadowRadius: 14,
293
- shadowOffset: { width: 0, height: 6 },
294
- elevation: 6,
295
- },
296
- elevated: {
297
- shadowColor: '#000',
298
- shadowOpacity: 0.1,
299
- shadowRadius: 10,
300
- shadowOffset: { width: 0, height: 3 },
301
- elevation: 3,
302
- },
303
290
  });
@@ -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.
@@ -248,11 +248,8 @@ export function Avatar({
248
248
  { width: px, height: px, borderRadius: faceRadius, backgroundColor: faceBg },
249
249
  showElevated
250
250
  ? {
251
- shadowColor: '#000',
252
- shadowOpacity: dark ? 0.5 : 0.14,
253
- shadowRadius: px * 0.16,
254
- shadowOffset: { width: 0, height: Math.max(2, px * 0.07) },
255
- elevation: Math.round(px * 0.1),
251
+ // boxShadow scales with the avatar and looks the same on iOS, Android and web.
252
+ boxShadow: `0px ${Math.round(Math.max(2, px * 0.07))}px ${Math.round(px * 0.32)}px rgba(0,0,0,${dark ? 0.5 : 0.14})`,
256
253
  }
257
254
  : null,
258
255
  ]}
@@ -781,13 +778,13 @@ function contrast(a: string, b: string) {
781
778
  const styles = StyleSheet.create({
782
779
  face: { overflow: 'visible' },
783
780
  clip: { flex: 1, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
784
- initials: { fontWeight: '400', includeFontPadding: false, textAlign: 'center' },
781
+ initials: { fontWeight: '400', includeFontPadding: false, textAlign: 'center', textAlignVertical: 'center' },
785
782
  inkShadow: { textShadowColor: 'rgba(0,0,0,0.18)', textShadowRadius: 2, textShadowOffset: { width: 0, height: 0.5 } },
786
783
  pulse: { position: 'absolute' },
787
784
  dot: { alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
788
785
  badge: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
789
- badgeText: { fontWeight: '400', letterSpacing: -0.2, includeFontPadding: false },
786
+ badgeText: { fontWeight: '400', letterSpacing: -0.2, includeFontPadding: false, textAlignVertical: 'center' },
790
787
  group: { flexDirection: 'row', alignItems: 'center' },
791
788
  more: { alignItems: 'center', justifyContent: 'center' },
792
- moreText: { fontWeight: '400', letterSpacing: -0.2 },
789
+ moreText: { fontWeight: '400', letterSpacing: -0.2, includeFontPadding: false, textAlignVertical: 'center' },
793
790
  });