@mrmeg/expo-ui 0.27.1 → 0.28.0

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 (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +87 -16
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +5 -2
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +73 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +20 -30
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +71 -26
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
@@ -1,12 +1,15 @@
1
- import { useCallback, useMemo, useRef } from "react";
1
+ import { useCallback, useMemo } from "react";
2
2
  import { Animated } from "react-native";
3
- import { hapticLight } from "../lib/haptics.js";
3
+ import { hapticLight, hapticPress } from "../lib/haptics.js";
4
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
5
+ import { shouldUseNativeDriver } from "../lib/animations.js";
4
6
  import { useReducedMotion } from "./useReduceMotion.js";
5
7
  /**
6
8
  * Hook for press-feedback scale animation using React Native Animated.
7
9
  *
8
- * Returns an animated style and onPressIn/onPressOut handlers to spread onto a Pressable.
9
- * Respects reduced motion preferences.
10
+ * Returns an animated style and onPressIn/onPressOut handlers to spread onto a
11
+ * Pressable. Under reduced motion the scale stays at 1 — the pressed opacity
12
+ * the components layer on carries the feedback instead.
10
13
  *
11
14
  * @example
12
15
  * ```tsx
@@ -20,27 +23,31 @@ import { useReducedMotion } from "./useReduceMotion.js";
20
23
  * ```
21
24
  */
22
25
  export function useScalePress(options = {}) {
23
- const { scaleTo = 0.97, haptic = true, damping = 20, stiffness = 300, disabled = false, } = options;
26
+ const { scaleTo = 0.97, haptic = "setting", damping = 20, stiffness = 300, disabled = false, } = options;
24
27
  const reduceMotion = useReducedMotion();
25
- const scale = useRef(new Animated.Value(1)).current;
28
+ const scale = useAnimatedValue(1);
26
29
  const animateTo = useCallback((toValue) => {
27
30
  scale.stopAnimation();
31
+ // Reduced motion: no scale change at all (not even an instant jump), so
32
+ // the surface stays still while its pressed opacity does the signalling.
28
33
  if (reduceMotion) {
29
- scale.setValue(toValue);
34
+ scale.setValue(1);
30
35
  return;
31
36
  }
32
37
  Animated.spring(scale, {
33
38
  toValue,
34
39
  damping,
35
40
  stiffness,
36
- useNativeDriver: true,
41
+ useNativeDriver: shouldUseNativeDriver,
37
42
  }).start();
38
43
  }, [damping, reduceMotion, scale, stiffness]);
39
44
  const onPressIn = useCallback(() => {
40
45
  if (disabled)
41
46
  return;
42
- if (haptic)
47
+ if (haptic === true)
43
48
  hapticLight();
49
+ else if (haptic === "setting")
50
+ hapticPress();
44
51
  animateTo(scaleTo);
45
52
  }, [animateTo, disabled, haptic, scaleTo]);
46
53
  const onPressOut = useCallback(() => {
@@ -0,0 +1,24 @@
1
+ import { type ShapeOverrides, type ShapeSlot } from "../state/themeStore.js";
2
+ /**
3
+ * The host app's `setShape` override for one slot, or `undefined` when the
4
+ * slot is untouched. One store subscription per component, re-rendering only
5
+ * when that slot changes.
6
+ *
7
+ * Layer it right after the component's static radius and before the caller's
8
+ * `style`, so the precedence stays caller → app → package:
9
+ *
10
+ * ```tsx
11
+ * const cardShape = useShape("card");
12
+ * <View style={[styles.card, shapeRadius(cardShape), style]} />
13
+ * ```
14
+ */
15
+ export declare function useShape<S extends ShapeSlot>(slot: S): ShapeOverrides[S] | undefined;
16
+ /**
17
+ * `{ borderRadius }` when the override sets one (zero included — square
18
+ * corners are a choice), otherwise `undefined` so a style array skips it.
19
+ */
20
+ export declare function shapeRadius(override: {
21
+ borderRadius?: number;
22
+ } | undefined): {
23
+ borderRadius: number;
24
+ } | undefined;
@@ -0,0 +1,24 @@
1
+ import { useThemeStore } from "../state/themeStore.js";
2
+ /**
3
+ * The host app's `setShape` override for one slot, or `undefined` when the
4
+ * slot is untouched. One store subscription per component, re-rendering only
5
+ * when that slot changes.
6
+ *
7
+ * Layer it right after the component's static radius and before the caller's
8
+ * `style`, so the precedence stays caller → app → package:
9
+ *
10
+ * ```tsx
11
+ * const cardShape = useShape("card");
12
+ * <View style={[styles.card, shapeRadius(cardShape), style]} />
13
+ * ```
14
+ */
15
+ export function useShape(slot) {
16
+ return useThemeStore((s) => s.shapeOverrides[slot]);
17
+ }
18
+ /**
19
+ * `{ borderRadius }` when the override sets one (zero included — square
20
+ * corners are a choice), otherwise `undefined` so a style array skips it.
21
+ */
22
+ export function shapeRadius(override) {
23
+ return override?.borderRadius === undefined ? undefined : { borderRadius: override.borderRadius };
24
+ }
@@ -1,7 +1,9 @@
1
- import { useEffect, useMemo, useRef } from "react";
1
+ import { useEffect, useMemo } from "react";
2
2
  import { Animated, Easing } from "react-native";
3
3
  import { useReducedMotion } from "./useReduceMotion.js";
4
4
  import { durations } from "../constants/motion.js";
5
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
6
+ import { shouldUseNativeDriver } from "../lib/animations.js";
5
7
  /**
6
8
  * Hook for entrance animations with stagger support using React Native Animated.
7
9
  *
@@ -24,15 +26,15 @@ import { durations } from "../constants/motion.js";
24
26
  export function useStaggeredEntrance(options = {}) {
25
27
  const { type = "fadeSlideUp", delay = 0, duration = durations.normal, slideDistance = 8, initialScale = 0.95, } = options;
26
28
  const reduceMotion = useReducedMotion();
27
- const opacity = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current;
28
- const translateY = useRef(new Animated.Value(reduceMotion
29
+ const opacity = useAnimatedValue(reduceMotion ? 1 : 0);
30
+ const translateY = useAnimatedValue(reduceMotion
29
31
  ? 0
30
32
  : type === "fadeSlideUp"
31
33
  ? slideDistance
32
34
  : type === "fadeSlideDown"
33
35
  ? -slideDistance
34
- : 0)).current;
35
- const scale = useRef(new Animated.Value(reduceMotion ? 1 : type === "scale" ? initialScale : 1)).current;
36
+ : 0);
37
+ const scale = useAnimatedValue(reduceMotion ? 1 : type === "scale" ? initialScale : 1);
36
38
  useEffect(() => {
37
39
  if (reduceMotion) {
38
40
  opacity.setValue(1);
@@ -50,7 +52,7 @@ export function useStaggeredEntrance(options = {}) {
50
52
  const timingConfig = {
51
53
  duration,
52
54
  easing: Easing.out(Easing.cubic),
53
- useNativeDriver: true,
55
+ useNativeDriver: shouldUseNativeDriver,
54
56
  };
55
57
  const animations = [
56
58
  Animated.timing(opacity, {
@@ -69,7 +71,7 @@ export function useStaggeredEntrance(options = {}) {
69
71
  toValue: 1,
70
72
  damping: 14,
71
73
  stiffness: 250,
72
- useNativeDriver: true,
74
+ useNativeDriver: shouldUseNativeDriver,
73
75
  }));
74
76
  }
75
77
  const animation = delay > 0
@@ -1,6 +1,6 @@
1
- import { Colors } from "../constants/colors";
1
+ import { Colors } from "../constants/colors.js";
2
2
  import { ImageStyle, TextStyle, ViewStyle } from "react-native";
3
- import { spacing as spacingConstants } from "../constants/spacing";
3
+ import { spacing as spacingConstants } from "../constants/spacing.js";
4
4
  type ShadowType = "base" | "soft" | "sharp" | "subtle" | "elevated" | "glow" | "glass" | "card" | "cardHover" | "cardSubtle";
5
5
  interface ExtendedColorScheme {
6
6
  theme: Colors["light" | "dark"];
@@ -32,7 +32,7 @@ interface ExtendedColorScheme {
32
32
  * - getTextColorForBackground("#000") → "light"
33
33
  * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
34
34
  * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
35
- * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
35
+ * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.1), 0px 4px 12px rgba(0, 0, 0, 0.06)" } (light)
36
36
  */
37
37
  export declare function useTheme(): ExtendedColorScheme & {
38
38
  toggleTheme: () => void;
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
- import { colors, resolveRawColor } from "../constants/colors.js";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { colors, palette, resolveRawColor } from "../constants/colors.js";
3
4
  import { Platform, StyleSheet } from "react-native";
4
5
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
6
  import { useThemeColorScope } from "../state/themeColorScope.js";
7
+ import { useInitialScheme } from "../state/initialScheme.js";
6
8
  import { spacing as spacingConstants } from "../constants/spacing.js";
7
9
  // Module-level cache for contrast calculations to avoid memory leak
8
10
  // and share across components
@@ -22,6 +24,144 @@ function getCachedOrCompute(key, compute) {
22
24
  contrastCache.set(key, result);
23
25
  return result;
24
26
  }
27
+ /**
28
+ * Web: keeps `<html data-theme>` and `color-scheme` — what the CSS variables
29
+ * from `getThemeCssVariables()` key on — on the store's resolved scheme.
30
+ *
31
+ * One store subscription for the whole app, started by the first `useTheme`
32
+ * consumer to mount (the same moment the old per-consumer effect first
33
+ * wrote), writing only when the resolved scheme changes. It used to be an
34
+ * effect in every consumer, which re-ran in each of them on every scheme
35
+ * switch.
36
+ */
37
+ let documentThemeSyncStarted = false;
38
+ let documentScheme = null;
39
+ function writeDocumentScheme(state) {
40
+ const scheme = resolveThemePreference(state.userTheme, state.systemTheme);
41
+ if (scheme === documentScheme)
42
+ return;
43
+ documentScheme = scheme;
44
+ document.documentElement.dataset.theme = scheme;
45
+ document.documentElement.style.colorScheme = scheme;
46
+ }
47
+ function startDocumentThemeSync() {
48
+ if (documentThemeSyncStarted || Platform.OS !== "web" || typeof document === "undefined") {
49
+ return;
50
+ }
51
+ documentThemeSyncStarted = true;
52
+ writeDocumentScheme(useThemeStore.getState());
53
+ useThemeStore.subscribe(writeDocumentScheme);
54
+ }
55
+ /**
56
+ * Shadow presets, per scheme.
57
+ *
58
+ * Light: the shadow does the work, at about twice the alpha the original
59
+ * presets used (0.04 became 0.08). Dark: the original presets tripled a 0.04
60
+ * alpha, which is invisible on the near-black base; these are black at real
61
+ * alpha, with the edge highlight in front. `subtle`, `soft` and `elevated`
62
+ * were tuned in the Surface Lab (`client/showcase/SurfaceLabScreen.tsx`); the
63
+ * other raised presets sit between them. `sharp` keeps its original light
64
+ * alpha and its original tripled dark alpha; `glow` is one vivid layer in both.
65
+ */
66
+ const SHADOW_CONFIGS = {
67
+ subtle: {
68
+ light: [
69
+ { x: 0, y: 1, blur: 3, opacity: 0.08 },
70
+ { x: 0, y: 2, blur: 8, opacity: 0.06 },
71
+ ],
72
+ dark: [
73
+ { x: 0, y: 1, blur: 2, opacity: 0.4 },
74
+ { x: 0, y: 8, blur: 24, opacity: 0.35 },
75
+ ],
76
+ highlight: 0.05,
77
+ },
78
+ base: {
79
+ light: [
80
+ { x: 0, y: 2, blur: 6, opacity: 0.1 },
81
+ { x: 0, y: 4, blur: 12, opacity: 0.06 },
82
+ ],
83
+ dark: [
84
+ { x: 0, y: 2, blur: 3, opacity: 0.4 },
85
+ { x: 0, y: 10, blur: 28, opacity: 0.38 },
86
+ ],
87
+ },
88
+ soft: {
89
+ light: [
90
+ { x: 0, y: 4, blur: 10, opacity: 0.1 },
91
+ { x: 0, y: 8, blur: 20, opacity: 0.06 },
92
+ ],
93
+ dark: [
94
+ { x: 0, y: 2, blur: 4, opacity: 0.45 },
95
+ { x: 0, y: 14, blur: 36, opacity: 0.45 },
96
+ ],
97
+ highlight: 0.06,
98
+ },
99
+ card: {
100
+ light: [
101
+ { x: 0, y: 4, blur: 12, opacity: 0.1 },
102
+ { x: 0, y: 8, blur: 24, opacity: 0.06 },
103
+ ],
104
+ dark: [
105
+ { x: 0, y: 2, blur: 4, opacity: 0.42 },
106
+ { x: 0, y: 10, blur: 30, opacity: 0.4 },
107
+ ],
108
+ highlight: 0.05,
109
+ },
110
+ cardSubtle: {
111
+ light: [
112
+ { x: 0, y: 1, blur: 3, opacity: 0.1 },
113
+ { x: 0, y: 3, blur: 9, opacity: 0.06 },
114
+ ],
115
+ dark: [
116
+ { x: 0, y: 1, blur: 2, opacity: 0.4 },
117
+ { x: 0, y: 4, blur: 12, opacity: 0.3 },
118
+ ],
119
+ highlight: 0.05,
120
+ },
121
+ cardHover: {
122
+ light: [
123
+ { x: 0, y: 8, blur: 24, opacity: 0.12 },
124
+ { x: 0, y: 16, blur: 48, opacity: 0.08 },
125
+ ],
126
+ dark: [
127
+ { x: 0, y: 3, blur: 6, opacity: 0.48 },
128
+ { x: 0, y: 18, blur: 48, opacity: 0.5 },
129
+ ],
130
+ highlight: 0.06,
131
+ },
132
+ elevated: {
133
+ light: [
134
+ { x: 0, y: 16, blur: 48, opacity: 0.16 },
135
+ { x: 0, y: 32, blur: 96, opacity: 0.1 },
136
+ ],
137
+ dark: [
138
+ { x: 0, y: 4, blur: 8, opacity: 0.5 },
139
+ { x: 0, y: 24, blur: 56, opacity: 0.55 },
140
+ ],
141
+ highlight: 0.06,
142
+ },
143
+ glass: {
144
+ light: [
145
+ { x: 0, y: 4, blur: 30, opacity: 0.1 },
146
+ { x: 0, y: 8, blur: 60, opacity: 0.06 },
147
+ ],
148
+ dark: [
149
+ { x: 0, y: 4, blur: 30, opacity: 0.4 },
150
+ { x: 0, y: 8, blur: 60, opacity: 0.3 },
151
+ ],
152
+ highlight: 0.05,
153
+ },
154
+ // Single layer — intentional crispness, not a soft dual-layer shadow.
155
+ sharp: {
156
+ light: [{ x: 0, y: 1, blur: 1, opacity: 0.15 }],
157
+ dark: [{ x: 0, y: 1, blur: 1, opacity: 0.45 }],
158
+ },
159
+ // Single layer — already a deliberate, vivid accent glow.
160
+ glow: {
161
+ light: [{ x: 0, y: 4, blur: 20, opacity: 0.4 }],
162
+ dark: [{ x: 0, y: 4, blur: 20, opacity: 0.4 }],
163
+ },
164
+ };
25
165
  /**
26
166
  * useTheme
27
167
  *
@@ -42,16 +182,26 @@ function getCachedOrCompute(key, compute) {
42
182
  * - getTextColorForBackground("#000") → "light"
43
183
  * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
44
184
  * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
45
- * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
185
+ * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.1), 0px 4px 12px rgba(0, 0, 0, 0.06)" } (light)
46
186
  */
47
187
  export function useTheme() {
48
- const userTheme = useThemeStore((s) => s.userTheme);
49
- const systemTheme = useThemeStore((s) => s.systemTheme);
50
- const setTheme = useThemeStore((s) => s.setTheme);
51
- const colorOverrides = useThemeStore((s) => s.colorOverrides);
188
+ // One store subscription per consumer, re-rendering only when one of these
189
+ // four fields changes (shallow compare), instead of four subscriptions.
190
+ const { userTheme, systemTheme, hasLoadedTheme, setTheme, colorOverrides } = useThemeStore(useShallow((s) => ({
191
+ userTheme: s.userTheme,
192
+ systemTheme: s.systemTheme,
193
+ hasLoadedTheme: s.hasLoadedTheme,
194
+ setTheme: s.setTheme,
195
+ colorOverrides: s.colorOverrides,
196
+ })));
52
197
  const scoped = useThemeColorScope();
53
- // Determine which theme to use (user preference or system)
54
- const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
198
+ const initialScheme = useInitialScheme();
199
+ // Determine which theme to use: the app's first-render hint until the
200
+ // persisted preference has been read (server render, hydration), then the
201
+ // user preference or system scheme. See state/initialScheme.tsx.
202
+ const effectiveScheme = initialScheme !== undefined && !hasLoadedTheme
203
+ ? initialScheme
204
+ : resolveThemePreference(userTheme, systemTheme);
55
205
  const base = colors[effectiveScheme];
56
206
  // Layer overrides on top of the package defaults for the active scheme, in
57
207
  // precedence order: package default → global store (app brand) → scoped
@@ -72,13 +222,9 @@ export function useTheme() {
72
222
  colors: { ...base.colors, ...storeOverride, ...scopedOverride },
73
223
  };
74
224
  }, [base, storeOverride, scopedOverride]);
75
- // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
76
- useEffect(() => {
77
- if (Platform.OS === "web" && typeof document !== "undefined") {
78
- document.documentElement.dataset.theme = effectiveScheme;
79
- document.documentElement.style.colorScheme = effectiveScheme;
80
- }
81
- }, [effectiveScheme]);
225
+ // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme.
226
+ // Starts the app-wide sync on the first mount; a no-op in every later one.
227
+ useEffect(startDocumentThemeSync, []);
82
228
  // Toggle between light, dark, and system themes
83
229
  const toggleTheme = useCallback(() => {
84
230
  if (userTheme === "light") {
@@ -105,61 +251,26 @@ export function useTheme() {
105
251
  * single-layer) is a dual-layer shadow: a tight "contact" layer plus a
106
252
  * wider, softer "ambient" layer, pitsi-ui-style. Layers are comma-joined
107
253
  * into one `boxShadow` value.
254
+ *
255
+ * Light and dark have their own layers. Light is the shadow alone. Dark is
256
+ * edge-lit: a 1px inset white highlight along the top edge, plus black
257
+ * layers at real alpha, deeper and larger than light's. On the near-black
258
+ * dark base a faint drop shadow is invisible, so the highlight carries the
259
+ * "lit from above" cue and the shadow only grounds the surface. The
260
+ * highlight is on the raised presets; `sharp` and `glow` have none.
108
261
  */
109
262
  const getShadowStyle = useCallback((type) => {
110
- // Each preset is one or more layers of [offsetX, offsetY, blurRadius, color, opacity].
111
- // Darker themes get a stronger alpha so shadows stay visible.
112
- const boost = theme.dark ? 3 : 1;
113
263
  const overlay = theme.colors.overlay;
114
- const shadowConfigs = {
115
- subtle: [
116
- { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.04 },
117
- { x: 0, y: 2, blur: 8, color: overlay, opacity: 0.03 },
118
- ],
119
- base: [
120
- { x: 0, y: 2, blur: 6, color: overlay, opacity: 0.05 },
121
- { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.03 },
122
- ],
123
- soft: [
124
- { x: 0, y: 4, blur: 10, color: overlay, opacity: 0.05 },
125
- { x: 0, y: 8, blur: 20, color: overlay, opacity: 0.03 },
126
- ],
127
- card: [
128
- { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.05 },
129
- { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.03 },
130
- ],
131
- cardSubtle: [
132
- { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.05 },
133
- { x: 0, y: 3, blur: 9, color: overlay, opacity: 0.03 },
134
- ],
135
- cardHover: [
136
- { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.06 },
137
- { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.04 },
138
- ],
139
- elevated: [
140
- { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.08 },
141
- { x: 0, y: 32, blur: 96, color: overlay, opacity: 0.05 },
142
- ],
143
- glass: [
144
- { x: 0, y: 4, blur: 30, color: overlay, opacity: 0.05 },
145
- { x: 0, y: 8, blur: 60, color: overlay, opacity: 0.03 },
146
- ],
147
- // Single layer — intentional crispness, not a soft dual-layer shadow.
148
- sharp: [
149
- { x: 0, y: 1, blur: 1, color: overlay, opacity: 0.15 },
150
- ],
151
- // Single layer — already a deliberate, vivid accent glow.
152
- glow: [
153
- { x: 0, y: 4, blur: 20, color: theme.colors.primary, opacity: 0.4 },
154
- ],
155
- };
156
- const layers = shadowConfigs[type].map(({ x, y, blur, color, opacity }) => {
157
- // Don't boost the glow accent — it's already a deliberate, vivid alpha.
158
- const alpha = color === theme.colors.primary ? opacity : Math.min(opacity * boost, 1);
159
- return `${x}px ${y}px ${blur}px ${withAlpha(color, alpha)}`;
160
- });
264
+ const config = SHADOW_CONFIGS[type];
265
+ const layers = theme.dark ? config.dark : config.light;
266
+ // Don't tint `glow` with the overlay: it is a deliberate, vivid accent glow.
267
+ const color = type === "glow" ? theme.colors.primary : overlay;
268
+ const parts = layers.map(({ x, y, blur, opacity }) => `${x}px ${y}px ${blur}px ${withAlpha(color, opacity)}`);
269
+ if (theme.dark && config.highlight !== undefined) {
270
+ parts.unshift(`inset 0px 1px 0px ${withAlpha(palette.white, config.highlight)}`);
271
+ }
161
272
  return {
162
- boxShadow: layers.join(", "),
273
+ boxShadow: parts.join(", "),
163
274
  };
164
275
  }, [theme]);
165
276
  const getFocusRingStyle = useCallback((offset = 2) => {
@@ -0,0 +1,13 @@
1
+ import { type EdgeInsets } from "react-native-safe-area-context";
2
+ /**
3
+ * The window's safe-area insets, wherever the component renders.
4
+ *
5
+ * `useSafeAreaInsets()` throws outside a `SafeAreaProvider` and reads zeros
6
+ * inside content the system presents outside the provider's view — an iOS
7
+ * `Modal`, a native bottom sheet, a `UIProvider` mounted above the app's
8
+ * provider. This reads the provider's context when it is there and falls back
9
+ * to `initialWindowMetrics` (the window the app launched in), edge by edge, so
10
+ * overlays, toasts and `Screen` clear the Dynamic Island and home indicator in
11
+ * every one of those cases. Zeros on web and in tests without a provider.
12
+ */
13
+ export declare function useWindowInsets(): EdgeInsets;
@@ -0,0 +1,23 @@
1
+ import { use } from "react";
2
+ import { SafeAreaInsetsContext, initialWindowMetrics, } from "react-native-safe-area-context";
3
+ /**
4
+ * The window's safe-area insets, wherever the component renders.
5
+ *
6
+ * `useSafeAreaInsets()` throws outside a `SafeAreaProvider` and reads zeros
7
+ * inside content the system presents outside the provider's view — an iOS
8
+ * `Modal`, a native bottom sheet, a `UIProvider` mounted above the app's
9
+ * provider. This reads the provider's context when it is there and falls back
10
+ * to `initialWindowMetrics` (the window the app launched in), edge by edge, so
11
+ * overlays, toasts and `Screen` clear the Dynamic Island and home indicator in
12
+ * every one of those cases. Zeros on web and in tests without a provider.
13
+ */
14
+ export function useWindowInsets() {
15
+ const context = use(SafeAreaInsetsContext);
16
+ const fallback = initialWindowMetrics?.insets;
17
+ return {
18
+ top: context?.top || fallback?.top || 0,
19
+ bottom: context?.bottom || fallback?.bottom || 0,
20
+ left: context?.left || fallback?.left || 0,
21
+ right: context?.right || fallback?.right || 0,
22
+ };
23
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export * from "./components";
2
- export * from "./constants";
3
- export * from "./hooks";
4
- export * from "./state";
5
- export * from "./lib";
1
+ export * from "./components/index.js";
2
+ export * from "./constants/index.js";
3
+ export * from "./hooks/index.js";
4
+ export * from "./state/index.js";
5
+ export * from "./lib/index.js";
@@ -0,0 +1,8 @@
1
+ import type { Ref, RefCallback } from "react";
2
+ /**
3
+ * One ref callback that feeds every ref given — object refs (`.current`) and
4
+ * callback refs alike, `null`/`undefined` skipped. For a component that needs
5
+ * the host node itself (e.g. to set a DOM attribute) while its consumer, or an
6
+ * `asChild` slot, also passes a `ref`.
7
+ */
8
+ export declare function composeRefs<T>(...refs: Array<Ref<T> | undefined>): RefCallback<T>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * One ref callback that feeds every ref given — object refs (`.current`) and
3
+ * callback refs alike, `null`/`undefined` skipped. For a component that needs
4
+ * the host node itself (e.g. to set a DOM attribute) while its consumer, or an
5
+ * `asChild` slot, also passes a `ref`.
6
+ */
7
+ export function composeRefs(...refs) {
8
+ return (node) => {
9
+ for (const ref of refs) {
10
+ if (!ref)
11
+ continue;
12
+ if (typeof ref === "function") {
13
+ ref(node);
14
+ }
15
+ else {
16
+ ref.current = node;
17
+ }
18
+ }
19
+ };
20
+ }
@@ -1,3 +1,15 @@
1
+ /** A light tap, unconditionally. Prefer `hapticPress` / `hapticSelection`. */
1
2
  export declare function hapticLight(): void;
2
3
  export declare function hapticMedium(): void;
3
4
  export declare function hapticSuccess(): void;
5
+ /**
6
+ * The tap a control fires when its state changes (Switch, Checkbox, Toggle,
7
+ * ToggleGroup, SegmentedControl). Fires under the `"selection"` (default) and
8
+ * `"all"` settings; silent under `"off"`.
9
+ */
10
+ export declare function hapticSelection(): void;
11
+ /**
12
+ * The tap a pressable fires on press-in (Button, pressable Card and Item,
13
+ * `useScalePress` consumers). Fires only under the `"all"` setting.
14
+ */
15
+ export declare function hapticPress(): void;
@@ -1,4 +1,5 @@
1
1
  import { Platform } from "react-native";
2
+ import { useFeedbackStore } from "../state/feedbackStore.js";
2
3
  function runHaptic(fn) {
3
4
  if (Platform.OS === "web")
4
5
  return;
@@ -9,6 +10,7 @@ function runHaptic(fn) {
9
10
  // Haptics not available
10
11
  }
11
12
  }
13
+ /** A light tap, unconditionally. Prefer `hapticPress` / `hapticSelection`. */
12
14
  export function hapticLight() {
13
15
  runHaptic(() => {
14
16
  const H = require("expo-haptics");
@@ -27,3 +29,22 @@ export function hapticSuccess() {
27
29
  H.notificationAsync(H.NotificationFeedbackType.Success);
28
30
  });
29
31
  }
32
+ /**
33
+ * The tap a control fires when its state changes (Switch, Checkbox, Toggle,
34
+ * ToggleGroup, SegmentedControl). Fires under the `"selection"` (default) and
35
+ * `"all"` settings; silent under `"off"`.
36
+ */
37
+ export function hapticSelection() {
38
+ if (useFeedbackStore.getState().haptics === "off")
39
+ return;
40
+ hapticLight();
41
+ }
42
+ /**
43
+ * The tap a pressable fires on press-in (Button, pressable Card and Item,
44
+ * `useScalePress` consumers). Fires only under the `"all"` setting.
45
+ */
46
+ export function hapticPress() {
47
+ if (useFeedbackStore.getState().haptics !== "all")
48
+ return;
49
+ hapticLight();
50
+ }
@@ -1,5 +1,6 @@
1
- export * from "./animations";
2
- export * from "./haptics";
3
- export * from "./i18n";
4
- export * from "./stateSurface";
5
- export * from "./themedStyles";
1
+ export * from "./animations.js";
2
+ export * from "./composeRefs.js";
3
+ export * from "./haptics.js";
4
+ export * from "./i18n.js";
5
+ export * from "./stateSurface.js";
6
+ export * from "./themedStyles.js";
package/dist/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./animations.js";
2
+ export * from "./composeRefs.js";
2
3
  export * from "./haptics.js";
3
4
  export * from "./i18n.js";
4
5
  export * from "./stateSurface.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The packaged Inter faces `useResources` registers through `expo-font` —
3
+ * web variant: none.
4
+ *
5
+ * Web loads Inter as one multi-weight CSS family from Google Fonts (see
6
+ * `useResources`), and `constants/fonts.ts` points every web weight at that
7
+ * family, so no static `.ttf` would ever be referenced. Importing one here
8
+ * would still make the bundler emit it as a web asset, so this variant imports
9
+ * nothing. Metro resolves `interFonts.native.ts` on iOS and Android instead.
10
+ *
11
+ * Private module: not re-exported from `lib/index.ts`, and `lib/*` is not an
12
+ * exported subpath, so consumers can't reach it.
13
+ */
14
+ export type InterFontMap = Readonly<Record<string, number>>;
15
+ export declare const interFontMap: InterFontMap | null;
@@ -0,0 +1 @@
1
+ export const interFontMap = null;
@@ -0,0 +1,3 @@
1
+ import type { InterFontMap } from "./interFonts.js";
2
+ export type { InterFontMap };
3
+ export declare const interFontMap: InterFontMap | null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The packaged Inter faces `useResources` registers through `expo-font` —
3
+ * native variant.
4
+ *
5
+ * Exactly the four static weights the native `sansSerif` family keys point at
6
+ * (see `constants/fonts.ts`), each imported from its own per-weight subpath.
7
+ * The `@expo-google-fonts/inter` root entry `require`s all 18 files (every
8
+ * weight, plus italics), and Metro ships every file a bundle requires, so
9
+ * importing it added 14 unused faces (~4.8 MB) to every native app.
10
+ */
11
+ import { Inter_400Regular } from "@expo-google-fonts/inter/400Regular";
12
+ import { Inter_500Medium } from "@expo-google-fonts/inter/500Medium";
13
+ import { Inter_600SemiBold } from "@expo-google-fonts/inter/600SemiBold";
14
+ import { Inter_700Bold } from "@expo-google-fonts/inter/700Bold";
15
+ export const interFontMap = {
16
+ Inter_400Regular,
17
+ Inter_500Medium,
18
+ Inter_600SemiBold,
19
+ Inter_700Bold,
20
+ };