@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.
@@ -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.
@@ -37,6 +37,8 @@ export function GlassTabBar({ state, navigation, insets }: BottomTabBarProps) {
37
37
  const activeTab = getTab(routes[activeIndex]?.name ?? '');
38
38
 
39
39
  const fullW = Math.min(screenW - 32, 520);
40
+ // Centre the capped bar on tablets and open foldables (phones keep the 16pt sides).
41
+ const side = Math.max(16, (screenW - fullW) / 2);
40
42
  const itemW = fullW / Math.max(routes.length, 1);
41
43
  const bubbleX = useRef(new Animated.Value(activeIndex * itemW)).current;
42
44
  const [collapsed, setCollapsed] = useState(false);
@@ -63,7 +65,7 @@ export function GlassTabBar({ state, navigation, insets }: BottomTabBarProps) {
63
65
  const dotScale = tabBarCollapse.interpolate({ inputRange: [0.55, 1], outputRange: [0.6, 1], extrapolate: 'clamp' });
64
66
 
65
67
  return (
66
- <View pointerEvents="box-none" style={[styles.host, { bottom: Math.max(insets.bottom, 12) }]}>
68
+ <View pointerEvents="box-none" style={[styles.host, { left: side, right: side, bottom: Math.max(insets.bottom, 12) }]}>
67
69
  <Animated.View style={[styles.shadow, { width }]}>
68
70
  <View style={[styles.glass, { borderColor: isDark ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.7)' }]}>
69
71
  <BlurView
@@ -225,11 +227,8 @@ const styles = StyleSheet.create({
225
227
  shadow: {
226
228
  height: GLASS_BAR_HEIGHT,
227
229
  borderRadius: GLASS_BAR_HEIGHT / 2,
228
- shadowColor: '#000',
229
- shadowOpacity: 0.18,
230
- shadowRadius: 22,
231
- shadowOffset: { width: 0, height: 10 },
232
- elevation: 12,
230
+ // boxShadow renders the same on iOS, Android (new arch) and web; elevation drew a grey Android shadow.
231
+ boxShadow: '0px 10px 22px rgba(0,0,0,0.18)',
233
232
  },
234
233
  glass: {
235
234
  flex: 1,
@@ -254,7 +253,7 @@ const styles = StyleSheet.create({
254
253
  alignItems: 'center',
255
254
  justifyContent: 'center',
256
255
  },
257
- badgeText: { color: '#fff', fontSize: 10, fontWeight: '400' },
256
+ badgeText: { color: '#fff', fontSize: 10, fontWeight: '400', includeFontPadding: false, textAlignVertical: 'center' },
258
257
  dot: { position: 'absolute', top: 0, left: 0, width: GLASS_BAR_HEIGHT, height: GLASS_BAR_HEIGHT },
259
258
  dotPress: { flex: 1, alignItems: 'center', justifyContent: 'center' },
260
259
  });
@@ -6,6 +6,7 @@ import {
6
6
  Text,
7
7
  View,
8
8
  useColorScheme,
9
+ useWindowDimensions,
9
10
  } from 'react-native';
10
11
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
11
12
 
@@ -27,6 +28,12 @@ export function TabScreen({ title, description, accent, action }: TabScreenProps
27
28
  const bottomInset = useTabBarInset(24);
28
29
  // Bottom-anchored UI sits on top of the bar's footprint, not on the screen edge.
29
30
  const actionBottom = useTabBarInset(12);
31
+ // Phones: full width, one card per row. Tablets and open foldables: a centred column, two cards per row.
32
+ const { width } = useWindowDimensions();
33
+ const columnW = Math.min(width, COLUMN_MAX);
34
+ const side = Math.max(0, (width - columnW) / 2) + 20;
35
+ const columns = width >= 600 ? 2 : 1;
36
+ const cardW = columns > 1 ? (columnW - 40 - CARD_GAP * (columns - 1)) / columns : undefined;
30
37
  const bg = isDark ? '#0f0f1a' : '#f2f2f7';
31
38
  const text = isDark ? '#fff' : '#1c1c1e';
32
39
  const sub = isDark ? '#9898a8' : '#6c6c70';
@@ -38,6 +45,7 @@ export function TabScreen({ title, description, accent, action }: TabScreenProps
38
45
  contentContainerStyle={[
39
46
  styles.scroll,
40
47
  { paddingTop: insets.top + 12, paddingBottom: bottomInset },
48
+ { width: '100%', maxWidth: COLUMN_MAX, alignSelf: 'center' },
41
49
  ]}
42
50
  showsVerticalScrollIndicator={false}
43
51
  // Pad by hand on every bar: useTabBarInset() already counts the floating bar and home indicator.
@@ -54,27 +62,29 @@ export function TabScreen({ title, description, accent, action }: TabScreenProps
54
62
  <Text style={[styles.title, { color: text }]}>{title}</Text>
55
63
  <Text style={[styles.subtitle, { color: sub }]}>{description}</Text>
56
64
 
57
- {SECTIONS.map((n) => (
58
- <View
59
- key={n}
60
- style={[
61
- styles.card,
62
- { backgroundColor: isDark ? '#1c1c2e' : '#ffffff' },
63
- ]}
64
- >
65
- <Text style={[styles.cardTitle, { color: text }]}>Section {n}</Text>
66
- <Text style={[styles.cardSub, { color: sub }]}>
67
- iOS 26+ gets the system Liquid Glass bar. Older iOS, Android and web get the
68
- blur glass fallback — both merge into a glass circle when you scroll down.
69
- </Text>
70
- </View>
71
- ))}
65
+ <View style={columns > 1 ? styles.grid : undefined}>
66
+ {SECTIONS.map((n) => (
67
+ <View
68
+ key={n}
69
+ style={[
70
+ styles.card,
71
+ { width: cardW, backgroundColor: isDark ? '#1c1c2e' : '#ffffff' },
72
+ ]}
73
+ >
74
+ <Text style={[styles.cardTitle, { color: text }]}>Section {n}</Text>
75
+ <Text style={[styles.cardSub, { color: sub }]}>
76
+ iOS 26+ gets the system Liquid Glass bar. Older iOS, Android and web get the
77
+ blur glass fallback — both merge into a glass circle when you scroll down.
78
+ </Text>
79
+ </View>
80
+ ))}
81
+ </View>
72
82
  {action ? <View style={{ height: 64 }} /> : null}
73
83
  </ScrollView>
74
84
  {action ? (
75
85
  <Pressable
76
86
  accessibilityRole="button"
77
- style={({ pressed }) => [styles.action, { bottom: actionBottom, backgroundColor: accent, opacity: pressed ? 0.85 : 1 }]}
87
+ style={({ pressed }) => [styles.action, { left: side, right: side, bottom: actionBottom, backgroundColor: accent, opacity: pressed ? 0.85 : 1 }]}
78
88
  >
79
89
  <Text style={styles.actionText}>{action}</Text>
80
90
  </Pressable>
@@ -103,6 +113,9 @@ export function TabScreenFromConfig({ tabName, action }: { tabName: string; acti
103
113
  }
104
114
 
105
115
  const SECTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
116
+ /** Widest the screen's column gets on tablets. */
117
+ const COLUMN_MAX = 720;
118
+ const CARD_GAP = 10;
106
119
 
107
120
  const styles = StyleSheet.create({
108
121
  root: { flex: 1 },
@@ -127,6 +140,7 @@ const styles = StyleSheet.create({
127
140
  },
128
141
  title: { fontSize: 34, fontWeight: '400', letterSpacing: -0.5 },
129
142
  subtitle: { fontSize: 15, marginTop: 8, marginBottom: 20, lineHeight: 22 },
143
+ grid: { flexDirection: 'row', flexWrap: 'wrap', columnGap: CARD_GAP },
130
144
  card: {
131
145
  borderRadius: 14,
132
146
  padding: 16,
@@ -143,5 +157,5 @@ const styles = StyleSheet.create({
143
157
  alignItems: 'center',
144
158
  justifyContent: 'center',
145
159
  },
146
- actionText: { color: '#fff', fontSize: 16, fontWeight: '500' },
160
+ actionText: { color: '#fff', fontSize: 16, fontWeight: '500', includeFontPadding: false, textAlignVertical: 'center' },
147
161
  });
@@ -507,7 +507,7 @@ const styles = StyleSheet.create({
507
507
  value: { fontSize: 15, flexShrink: 1 },
508
508
  dot: { width: 9, height: 9, borderRadius: 5 },
509
509
  badge: { minWidth: 22, minHeight: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
510
- badgeText: { fontSize: 12, fontWeight: '400', fontVariant: ['tabular-nums'] },
510
+ badgeText: { fontSize: 12, fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlignVertical: 'center' },
511
511
  divider: { position: 'absolute', right: 0, bottom: 0, height: StyleSheet.hairlineWidth },
512
512
  avatar: { alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
513
513
  online: { position: 'absolute', right: -1, bottom: -1, borderWidth: 2 },
@@ -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.
@@ -403,7 +403,7 @@ const styles = StyleSheet.create({
403
403
  row: { flexDirection: 'row', gap: GAP, paddingHorizontal: 4, paddingVertical: 4, justifyContent: 'center', alignItems: 'center' },
404
404
  halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4 },
405
405
  cell: { flex: 1, alignItems: 'center', justifyContent: 'center' },
406
- char: { fontWeight: '400', fontVariant: ['tabular-nums'], textAlign: 'center' },
406
+ char: { fontWeight: '400', fontVariant: ['tabular-nums'], textAlign: 'center', includeFontPadding: false, textAlignVertical: 'center' },
407
407
  caret: { width: 2, borderRadius: 1 },
408
408
  sep: { alignItems: 'center', justifyContent: 'center' },
409
409
  dash: { width: 10, height: 2, borderRadius: 1, opacity: 0.6 },