@expokit/cli 0.4.3 → 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 (100) hide show
  1. package/bin/lib.mjs +3 -1
  2. package/package.json +1 -1
  3. package/registry/_docs/flip-calendar.json +106 -0
  4. package/registry/_docs/paywall.json +144 -0
  5. package/registry/_docs/ruler-time-picker.json +147 -0
  6. package/registry/accordion/files/src/lib/kit.tsx +102 -4
  7. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +8 -11
  8. package/registry/action-sheet/files/src/lib/kit.tsx +102 -4
  9. package/registry/alert/files/src/components/ui/alert.tsx +1 -1
  10. package/registry/alert/files/src/components/ui/button.tsx +5 -18
  11. package/registry/alert/files/src/lib/kit.tsx +102 -4
  12. package/registry/avatar/files/src/components/ui/avatar.tsx +5 -8
  13. package/registry/avatar/files/src/lib/kit.tsx +102 -4
  14. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  15. package/registry/badge/files/src/lib/kit.tsx +102 -4
  16. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -17
  17. package/registry/bottom-nav/files/src/lib/kit.tsx +102 -4
  18. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -14
  19. package/registry/bottom-sheet/files/src/lib/kit.tsx +102 -4
  20. package/registry/button/files/src/components/ui/button.tsx +5 -18
  21. package/registry/button/files/src/lib/kit.tsx +102 -4
  22. package/registry/button-3d/files/src/components/ui/button-3d.tsx +4 -7
  23. package/registry/button-3d/files/src/lib/kit.tsx +102 -4
  24. package/registry/calendar/files/src/components/ui/calendar.tsx +3 -3
  25. package/registry/calendar/files/src/lib/kit.tsx +102 -4
  26. package/registry/card/files/src/components/ui/card.tsx +4 -3
  27. package/registry/card/files/src/lib/kit.tsx +102 -4
  28. package/registry/checkbox/files/src/lib/kit.tsx +102 -4
  29. package/registry/chip-group/files/src/components/ui/chip-group.tsx +2 -2
  30. package/registry/chip-group/files/src/lib/kit.tsx +102 -4
  31. package/registry/confetti/files/src/lib/kit.tsx +102 -4
  32. package/registry/coupon/files/src/components/ui/coupon.tsx +5 -10
  33. package/registry/coupon/files/src/components/ui/flip-card.tsx +3 -9
  34. package/registry/coupon/files/src/components/ui/tilt-card.tsx +4 -10
  35. package/registry/coupon/files/src/lib/kit.tsx +102 -4
  36. package/registry/date-picker/files/src/components/ui/calendar.tsx +3 -3
  37. package/registry/date-picker/files/src/components/ui/date-picker.tsx +10 -16
  38. package/registry/date-picker/files/src/lib/kit.tsx +102 -4
  39. package/registry/dialog/files/src/components/ui/dialog.tsx +6 -12
  40. package/registry/dialog/files/src/lib/kit.tsx +102 -4
  41. package/registry/drawer/files/src/components/ui/drawer.tsx +3 -19
  42. package/registry/drawer/files/src/lib/kit.tsx +102 -4
  43. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +626 -0
  44. package/registry/flip-calendar/files/src/lib/kit.tsx +320 -0
  45. package/registry/flip-calendar.json +17 -0
  46. package/registry/flip-card/files/src/components/ui/flip-card.tsx +3 -9
  47. package/registry/flip-card/files/src/lib/kit.tsx +102 -4
  48. package/registry/floating-input/files/src/lib/kit.tsx +102 -4
  49. package/registry/grain/files/src/lib/kit.tsx +102 -4
  50. package/registry/kit/files/src/lib/kit.tsx +102 -4
  51. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +6 -7
  52. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -17
  53. package/registry/list-item/files/src/components/ui/list-item.tsx +1 -1
  54. package/registry/list-item/files/src/lib/kit.tsx +102 -4
  55. package/registry/otp-input/files/src/components/ui/otp-input.tsx +1 -1
  56. package/registry/otp-input/files/src/lib/kit.tsx +102 -4
  57. package/registry/password-input/files/src/lib/kit.tsx +102 -4
  58. package/registry/payment-card/files/src/components/ui/flip-card.tsx +3 -9
  59. package/registry/payment-card/files/src/components/ui/payment-card.tsx +9 -3
  60. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +4 -10
  61. package/registry/payment-card/files/src/lib/kit.tsx +102 -4
  62. package/registry/paywall.json +22 -0
  63. package/registry/progress/files/src/components/ui/progress.tsx +2 -0
  64. package/registry/progress/files/src/lib/kit.tsx +102 -4
  65. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +1 -1
  66. package/registry/progress-steps/files/src/lib/kit.tsx +102 -4
  67. package/registry/radio-group/files/src/lib/kit.tsx +102 -4
  68. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +816 -0
  69. package/registry/ruler-time-picker/files/src/lib/kit.tsx +320 -0
  70. package/registry/ruler-time-picker.json +19 -0
  71. package/registry/search-bar/files/src/components/ui/search-bar.tsx +3 -7
  72. package/registry/search-bar/files/src/lib/kit.tsx +102 -4
  73. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +3 -10
  74. package/registry/segmented-control/files/src/lib/kit.tsx +102 -4
  75. package/registry/select/files/src/components/ui/select.tsx +16 -18
  76. package/registry/select/files/src/lib/kit.tsx +102 -4
  77. package/registry/skeleton/files/src/lib/kit.tsx +102 -4
  78. package/registry/slider/files/src/components/ui/slider.tsx +4 -13
  79. package/registry/slider/files/src/lib/kit.tsx +102 -4
  80. package/registry/spinner/files/src/lib/kit.tsx +102 -4
  81. package/registry/stateful-button/files/src/lib/kit.tsx +102 -4
  82. package/registry/stepper/files/src/components/ui/stepper.tsx +2 -2
  83. package/registry/stepper/files/src/lib/kit.tsx +102 -4
  84. package/registry/switch/files/src/components/ui/switch.tsx +2 -5
  85. package/registry/switch/files/src/lib/kit.tsx +102 -4
  86. package/registry/tabs/files/src/components/ui/tabs.tsx +2 -2
  87. package/registry/tabs/files/src/lib/kit.tsx +102 -4
  88. package/registry/text-animation/files/src/lib/kit.tsx +102 -4
  89. package/registry/textarea/files/src/lib/kit.tsx +102 -4
  90. package/registry/ticket-shape/files/src/lib/kit.tsx +102 -4
  91. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +4 -10
  92. package/registry/tilt-card/files/src/lib/kit.tsx +102 -4
  93. package/registry/time-picker/files/src/components/ui/time-picker.tsx +16 -14
  94. package/registry/time-picker/files/src/lib/kit.tsx +102 -4
  95. package/registry/timeline/files/src/components/ui/avatar.tsx +5 -8
  96. package/registry/timeline/files/src/components/ui/timeline.tsx +5 -16
  97. package/registry/timeline/files/src/lib/kit.tsx +102 -4
  98. package/registry/toast/files/src/components/ui/button.tsx +5 -18
  99. package/registry/toast/files/src/components/ui/toast.tsx +2 -2
  100. 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.
@@ -15,14 +15,13 @@ import {
15
15
  Text,
16
16
  TextInput,
17
17
  View,
18
- useWindowDimensions,
19
18
  type StyleProp,
20
19
  type TextStyle,
21
20
  type ViewStyle,
22
21
  } from 'react-native';
23
22
  import Svg, { Circle, Path } from 'react-native-svg';
24
23
 
25
- import { alpha, motion, readableOn, useKit } from '@/lib/kit';
24
+ import { alpha, motion, readableOn, useKit, useLayout } from '@/lib/kit';
26
25
 
27
26
  export type SelectPresentation = 'dropdown' | 'sheet';
28
27
  /** @deprecated Use `SelectPresentation`. */
@@ -193,7 +192,7 @@ export function Select<T extends string | number = string>({
193
192
  optionStyle,
194
193
  optionTextStyle,
195
194
  }: SelectProps<T>) {
196
- const { colors, radius, dark, font } = useKit();
195
+ const { colors, radius, dark, font, shadow } = useKit();
197
196
  const reduce = useReduceMotion();
198
197
  const ff = font.family ? { fontFamily: font.family } : null;
199
198
  const tint = accent ?? colors.accent;
@@ -205,10 +204,12 @@ export function Select<T extends string | number = string>({
205
204
  const HEIGHT = typeof userField.height === 'number' ? userField.height : sz.h;
206
205
  const fieldRadius = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.scale(12 + sz.corner);
207
206
  const panelRadius = Math.max(fieldRadius, radius.scale(14));
208
- const { width: winW, height: winH } = useWindowDimensions();
207
+ const layout = useLayout();
208
+ const { width: winW, height: winH } = layout;
209
209
  const items = useMemo(() => options.map(normalize), [options]);
210
210
  const asSheet = (presentation ?? variant ?? 'dropdown') === 'sheet';
211
- const wide = winW >= 640;
211
+ // A centred card whenever the kit caps the sheet (foldables, tablets, landscape phones).
212
+ const wide = layout.sheetWidth < winW;
212
213
 
213
214
  const [inner, setInner] = useState<T | null>(defaultValue);
214
215
  const [innerMany, setInnerMany] = useState<T[]>(defaultValues ?? []);
@@ -866,8 +867,14 @@ export function Select<T extends string | number = string>({
866
867
  styles.sheet,
867
868
  { backgroundColor: colors.popover, maxHeight: winH * (wide ? 0.7 : 0.78), borderColor: colors.border },
868
869
  wide
869
- ? { width: Math.min(460, winW - 48), borderRadius: radius.xl, paddingBottom: 8 }
870
- : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 28 },
870
+ ? { width: Math.min(460, layout.sheetWidth), borderRadius: radius.xl, paddingBottom: 8, ...shadow('xl') }
871
+ : {
872
+ alignSelf: 'stretch',
873
+ borderTopLeftRadius: radius.xl + 4,
874
+ borderTopRightRadius: radius.xl + 4,
875
+ paddingBottom: 28,
876
+ boxShadow: `0px -4px 32px rgba(0,0,0,${dark ? 0.45 : 0.18})`,
877
+ },
871
878
  menuStyle,
872
879
  wide
873
880
  ? {
@@ -912,7 +919,7 @@ export function Select<T extends string | number = string>({
912
919
  backgroundColor: colors.popover,
913
920
  borderColor: colors.border,
914
921
  borderRadius: panelRadius,
915
- shadowOpacity: dark ? 0.5 : 0.12,
922
+ ...shadow('lg'),
916
923
  transformOrigin: below ? 'top' : 'bottom',
917
924
  },
918
925
  menuStyle,
@@ -1379,7 +1386,7 @@ const styles = StyleSheet.create({
1379
1386
  chip: { flexDirection: 'row', alignItems: 'center', gap: 4, maxWidth: 150, flexShrink: 1 },
1380
1387
  more: { gap: 0, paddingHorizontal: 9, flexShrink: 0 },
1381
1388
  chipIcon: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
1382
- chipText: { fontWeight: '400', flexShrink: 1 },
1389
+ chipText: { fontWeight: '400', flexShrink: 1, includeFontPadding: false, textAlignVertical: 'center' },
1383
1390
  tabular: { fontVariant: ['tabular-nums'], fontWeight: '400' },
1384
1391
  roll: { overflow: 'hidden' },
1385
1392
  rollPrev: { position: 'absolute', left: 0, top: 0 },
@@ -1398,11 +1405,6 @@ const styles = StyleSheet.create({
1398
1405
  sheet: {
1399
1406
  paddingHorizontal: 12,
1400
1407
  borderWidth: StyleSheet.hairlineWidth,
1401
- shadowColor: '#000',
1402
- shadowOpacity: 0.18,
1403
- shadowRadius: 24,
1404
- shadowOffset: { width: 0, height: -4 },
1405
- elevation: 16,
1406
1408
  },
1407
1409
  sheetHeader: { alignItems: 'center', paddingTop: 10, paddingBottom: 12 },
1408
1410
  grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
@@ -1453,10 +1455,6 @@ const styles = StyleSheet.create({
1453
1455
  dropdown: {
1454
1456
  position: 'absolute',
1455
1457
  borderWidth: StyleSheet.hairlineWidth,
1456
- shadowColor: '#000',
1457
- shadowRadius: 28,
1458
- shadowOffset: { width: 0, height: 10 },
1459
- elevation: 10,
1460
1458
  overflow: 'hidden',
1461
1459
  },
1462
1460
  row: { minHeight: 52, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, gap: 12 },
@@ -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.
@@ -173,7 +173,7 @@ export function Slider(props: SliderProps) {
173
173
  const onSlidingStart = props.onSlidingStart as Callback;
174
174
  const onSlidingComplete = props.onSlidingComplete as Callback;
175
175
 
176
- const { colors, radius, dark, font } = useKit();
176
+ const { colors, radius, dark, font, shadow } = useKit();
177
177
  const ff = font.family ? { fontFamily: font.family } : null;
178
178
  const reduce = useReduceMotion();
179
179
  const tint = accent ?? colors.accent;
@@ -566,7 +566,7 @@ export function Slider(props: SliderProps) {
566
566
  borderRadius: radius.capsule(T),
567
567
  backgroundColor: thumbColor,
568
568
  borderColor: dark ? 'transparent' : colors.border,
569
- shadowOpacity: dark ? 0.5 : 0.2,
569
+ boxShadow: `0px 2px 6px rgba(0,0,0,${dark ? 0.5 : 0.2})`,
570
570
  transform: anim.thumb[i],
571
571
  },
572
572
  thumbStyle,
@@ -581,7 +581,7 @@ export function Slider(props: SliderProps) {
581
581
  { bottom: bubbleBottom, left: slotW / 2 - BUBBLE_W / 2, opacity: bubble[i], transform: anim.bubble[i] },
582
582
  ]}
583
583
  >
584
- <View style={[styles.bubble, { backgroundColor: colors.foreground, borderRadius: radius.capsule(BUBBLE_H) }]}>
584
+ <View style={[styles.bubble, { backgroundColor: colors.foreground, borderRadius: radius.capsule(BUBBLE_H) }, shadow('md')]}>
585
585
  <Text numberOfLines={1} selectable={false} maxFontSizeMultiplier={FONT_CAP} style={[styles.bubbleText, ff, { color: colors.background, fontSize: sz.font }, labelStyle]}>
586
586
  {fmt(v)}
587
587
  </Text>
@@ -721,10 +721,6 @@ const styles = StyleSheet.create({
721
721
  halo: { position: 'absolute', left: 0 },
722
722
  thumb: {
723
723
  borderWidth: StyleSheet.hairlineWidth,
724
- shadowColor: '#000',
725
- shadowRadius: 4,
726
- shadowOffset: { width: 0, height: 2 },
727
- elevation: 3,
728
724
  },
729
725
  bubbleWrap: { position: 'absolute', width: BUBBLE_W, alignItems: 'center', transformOrigin: 'bottom' },
730
726
  bubble: {
@@ -733,13 +729,8 @@ const styles = StyleSheet.create({
733
729
  minWidth: 36,
734
730
  alignItems: 'center',
735
731
  justifyContent: 'center',
736
- shadowColor: '#000',
737
- shadowOpacity: 0.18,
738
- shadowRadius: 8,
739
- shadowOffset: { width: 0, height: 3 },
740
- elevation: 4,
741
732
  },
742
- bubbleText: { fontWeight: '400', fontVariant: ['tabular-nums'] },
733
+ bubbleText: { fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlignVertical: 'center' },
743
734
  labels: { marginTop: 2 },
744
735
  markLabel: { position: 'absolute', width: 80, textAlign: 'center', fontWeight: '400', fontVariant: ['tabular-nums'] },
745
736
  });