@mrmeg/expo-ui 0.21.0 → 0.23.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 (36) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LLM_USAGE.md +8 -1
  3. package/README.md +18 -0
  4. package/dist/components/Accordion.js +1 -1
  5. package/dist/components/BottomSheet.js +1 -1
  6. package/dist/components/Card.js +7 -7
  7. package/dist/components/Checkbox.js +1 -1
  8. package/dist/components/Dialog.js +24 -5
  9. package/dist/components/DismissKeyboard.js +1 -1
  10. package/dist/components/EmptyState.js +2 -2
  11. package/dist/components/Item.d.ts +3 -2
  12. package/dist/components/Item.js +8 -6
  13. package/dist/components/KeyboardAvoidingView.d.ts +4 -2
  14. package/dist/components/KeyboardAvoidingView.js +5 -3
  15. package/dist/components/Notification.js +7 -7
  16. package/dist/components/RadioGroup.js +1 -1
  17. package/dist/components/StatCard.js +1 -1
  18. package/dist/components/keyboardController.d.ts +46 -0
  19. package/dist/components/keyboardController.js +89 -0
  20. package/dist/components/keyboardController.native.d.ts +9 -0
  21. package/dist/components/keyboardController.native.js +9 -0
  22. package/dist/constants/colors.d.ts +34 -0
  23. package/dist/constants/colors.js +93 -3
  24. package/dist/constants/spacing.d.ts +16 -6
  25. package/dist/constants/spacing.js +17 -6
  26. package/dist/hooks/useDimensions.d.ts +7 -5
  27. package/dist/hooks/useDimensions.js +27 -8
  28. package/dist/hooks/useResources.d.ts +17 -0
  29. package/dist/hooks/useResources.js +29 -0
  30. package/dist/hooks/useTheme.d.ts +18 -0
  31. package/dist/hooks/useTheme.js +43 -13
  32. package/dist/state/SsrViewportContext.d.ts +12 -0
  33. package/dist/state/SsrViewportContext.js +13 -0
  34. package/dist/state/index.d.ts +1 -0
  35. package/dist/state/index.js +1 -0
  36. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -3,6 +3,89 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.23.0]
7
+
8
+ ### Added
9
+
10
+ - **Density tokens.** `spacing` gains `smd` (12) and `mdl` (20) scale steps plus
11
+ semantic layout tokens: `screenPadding`, `sectionSpacing` (now 24),
12
+ `cardPadding`, `dialogPadding`, `rowPaddingY`/`rowPaddingX`, `rowGap`,
13
+ `rowMinHeight`, and `formRowMinHeight`. Components read these so a single
14
+ retune shifts the whole library's density.
15
+
16
+ ### Changed
17
+
18
+ - **Compact surfaces and rows.** `Card` header/content/footer padding drops
19
+ from 24 to 16 with a 16px title. `Item` rows use 10/16 padding and a 12px
20
+ gap, with a 40px visual min height on web while native keeps the 44pt touch
21
+ target. `Checkbox` and `RadioGroup` rows are 32px on web (native unchanged).
22
+ `Dialog`/`AlertDialog` padding is 20, `StatCard` padding is 16,
23
+ `EmptyState` vertical padding is 32, and `Accordion` triggers use 12px
24
+ vertical padding.
25
+ - **Dialog spacing is symmetric.** `DialogContent` and `AlertDialogContent`
26
+ space their children with a 16px gap instead of fixed header/footer
27
+ margins, so a header-only dialog no longer reads bottom-heavy.
28
+
29
+ ### Fixed
30
+
31
+ - **Dialog could not be dismissed on web.** The web overlay primitive wrapped
32
+ the backdrop in a react-native-web `Pressable` whose click handler stopped
33
+ propagation, so outside presses (mouse and touch) and Escape never reached
34
+ Radix. `DialogContent` now renders the overlay `asChild` on web; native is
35
+ unchanged.
36
+
37
+ ## [0.22.0]
38
+
39
+ ### Added
40
+
41
+ - **CSS-variable theming on web.** On web, every semantic theme color
42
+ (`theme.colors.*`) now resolves to a CSS custom property
43
+ (`var(--c-<kebab-token>)`) instead of a literal hex string, so styles built
44
+ from the theme — including the HTML shells baked at export time — re-theme
45
+ purely in CSS when `html[data-theme]` changes. Native platforms keep
46
+ literal values. New exports from `constants`:
47
+ - `getThemeCssVariables(overrides?)` — the `--c-*` (and companion
48
+ `--c-*-rgb` triplet) definitions per scheme, for embedding in an app's
49
+ `+html.tsx` global `<style>`. Accepts per-scheme brand-color overrides.
50
+ - `rawThemeColors.light/.dark` — the literal hex/rgba maps on every
51
+ platform, for sinks that cannot take `var()` (e.g.
52
+ `<meta name="theme-color">`).
53
+ - `resolveRawColor(color, scheme)` — maps a `var(--c-*)` theme color back
54
+ to the given scheme's literal value; literal inputs pass through.
55
+ - **`withAlpha` is exported standalone** (in addition to the `useTheme`
56
+ return) so module-scope style factories can import it. It understands
57
+ `var(--c-*)` inputs and rewrites them onto the `--c-*-rgb` triplet
58
+ (`var(--c-unset, rgba(var(--c-x-rgb), a))` — the never-defined outer
59
+ variable makes react-native-web's compiler pass the value through), staying
60
+ pure CSS that re-themes with the variable. Hex-suffix alpha concatenation
61
+ (`theme.colors.x + "15"`) does not work with `var()` colors — use
62
+ `withAlpha(theme.colors.x, 0.08)`.
63
+
64
+ ### Changed
65
+
66
+ - The contrast helpers from `useTheme` (`getContrastingColor`,
67
+ `getContrastRatio`, `getTextColorForBackground`) resolve `var(--c-*)`
68
+ inputs to the active scheme's literal value before parsing, and
69
+ `getContrastingColor` returns the winning candidate as originally passed
70
+ (a `var()` input stays a `var()` output).
71
+ - The React Navigation theme maps (`colors.<scheme>.navigation`) stay
72
+ literal on web as well as native: @react-navigation internals parse their
73
+ theme colors with a color library, which cannot take `var()` references.
74
+
75
+ ## [0.21.1]
76
+
77
+ ### Fixed
78
+
79
+ - **Exported web HTML renders icon glyphs, fixing React hydration error
80
+ #418 on every cold load.** `useResources` registers the Feather icon font
81
+ per render scope during `expo export`'s Node rendering
82
+ (`ensureIconFontRegistered`), so the static shell emits the glyph and its
83
+ font styles. Previously the shell contained an empty icon text node while
84
+ the client's first render (font pre-loaded at module scope) contained the
85
+ glyph — React 19 threw #418 and regenerated the tree on every cold load.
86
+ The same mechanism existed during the request-time SSR era and was removed
87
+ with it; static export needs it too.
88
+
6
89
  ## [0.21.0]
7
90
 
8
91
  ### Added
package/LLM_USAGE.md CHANGED
@@ -157,11 +157,18 @@ semantic colors without destructuring `withAlpha` outside the factory:
157
157
  const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
158
158
  card: {
159
159
  backgroundColor: withAlpha(theme.colors.primary, 0.08),
160
- padding: spacing.md,
160
+ padding: spacing.cardPadding,
161
161
  },
162
162
  }));
163
163
  ```
164
164
 
165
+ Layout spacing uses semantic density tokens rather than raw scale steps:
166
+ `spacing.screenPadding` (16) for screen and block gutters, `spacing.cardPadding`
167
+ (16) for bordered panels, `spacing.sectionSpacing` (24) between grouped lists,
168
+ `spacing.dialogPadding` (20) for dialogs, and `spacing.rowPaddingY`/`rowPaddingX`
169
+ (10/16) with `spacing.rowGap` (12) for list rows. `Item` already applies the row
170
+ tokens and keeps a 44px hit area on native while rendering 40px on web.
171
+
165
172
  When the saved theme preference is `system`, the package theme store owns the
166
173
  OS color-scheme subscription, including web `prefers-color-scheme`. Do not add
167
174
  app-local Appearance or `matchMedia` listeners for package components; import
package/README.md CHANGED
@@ -124,6 +124,24 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
124
124
  }));
125
125
  ```
126
126
 
127
+ ### Spacing and density
128
+
129
+ `spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md` 16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64) plus semantic density tokens that package components read from. Reach for the semantic names in app code so a future density tune lands everywhere at once:
130
+
131
+ | Token | Value | Use |
132
+ |-------|-------|-----|
133
+ | `screenPadding` | 16 | Horizontal gutter for screens, scroll content, and landing blocks |
134
+ | `sectionSpacing` | 24 | Gap between grouped lists, cards, or settings groups |
135
+ | `cardPadding` | 16 | `Card`, `StatCard`, and any bordered panel |
136
+ | `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
137
+ | `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows and hand-rolled list rows |
138
+ | `rowGap` | 12 | Gap between row media, content, and actions |
139
+ | `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
140
+ | `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
141
+ | `touchTarget` | 44 | Minimum native hit area; pair a smaller visual height with `hitSlop` |
142
+
143
+ Controls (`Button`, `TextInput`, `Select`, `Tabs`, `Toggle`) size themselves from their own `size` prop (28/32/36/40) and do not read these tokens.
144
+
127
145
  ### Color overrides
128
146
 
129
147
  The package ships a neutral default palette. `useTheme()` resolves colors in
@@ -132,7 +132,7 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
132
132
  justifyContent: "space-between",
133
133
  gap: spacing.md,
134
134
  borderRadius: spacing.radiusMd,
135
- paddingVertical: spacing.md,
135
+ paddingVertical: spacing.smd,
136
136
  ...(Platform.OS === "web" && {
137
137
  cursor: "pointer",
138
138
  userSelect: "none",
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
3
3
  import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
- import { KeyboardController } from "react-native-keyboard-controller";
5
+ import { KeyboardController } from "./keyboardController";
6
6
  import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
@@ -98,24 +98,24 @@ const createCardStyles = (theme) => StyleSheet.create({
98
98
  backgroundColor: "transparent",
99
99
  },
100
100
  header: {
101
- padding: spacing.lg,
101
+ padding: spacing.cardPadding,
102
102
  paddingBottom: spacing.xs,
103
103
  gap: spacing.xs,
104
104
  },
105
105
  content: {
106
- paddingHorizontal: spacing.lg,
107
- paddingBottom: spacing.lg,
106
+ paddingHorizontal: spacing.cardPadding,
107
+ paddingBottom: spacing.cardPadding,
108
108
  },
109
109
  footer: {
110
110
  flexDirection: "row",
111
111
  alignItems: "center",
112
- paddingHorizontal: spacing.lg,
113
- paddingBottom: spacing.lg,
112
+ paddingHorizontal: spacing.cardPadding,
113
+ paddingBottom: spacing.cardPadding,
114
114
  paddingTop: 0,
115
115
  },
116
116
  title: {
117
- fontSize: 18,
118
- lineHeight: 24,
117
+ fontSize: 16,
118
+ lineHeight: 22,
119
119
  letterSpacing: 0,
120
120
  },
121
121
  description: {
@@ -117,7 +117,7 @@ const styles = StyleSheet.create({
117
117
  flexDirection: "row",
118
118
  alignItems: "center",
119
119
  gap: spacing.sm,
120
- minHeight: spacing.touchTarget,
120
+ minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
121
121
  },
122
122
  labelContainer: {
123
123
  flex: 1,
@@ -22,7 +22,25 @@ function DialogRoot({ children, ...props }) {
22
22
  function DialogContent({ portalHost, style, children, ...props }) {
23
23
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
24
24
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
25
- return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay, { style: StyleSheet.flatten([
25
+ return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
26
+ // On web the primitive wraps the overlay in a react-native-web
27
+ // Pressable whose click handler stops propagation unconditionally.
28
+ // With that Pressable between the overlay and the Radix content,
29
+ // outside presses (mouse and touch) and Escape never dismissed the
30
+ // dialog, so a dialog without a Close button was stuck. `asChild`
31
+ // makes the primitive clone our fade wrapper instead, removing the
32
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
33
+ // which is what implements closeOnPress there.
34
+ , {
35
+ // On web the primitive wraps the overlay in a react-native-web
36
+ // Pressable whose click handler stops propagation unconditionally.
37
+ // With that Pressable between the overlay and the Radix content,
38
+ // outside presses (mouse and touch) and Escape never dismissed the
39
+ // dialog, so a dialog without a Close button was stuck. `asChild`
40
+ // makes the primitive clone our fade wrapper instead, removing the
41
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
42
+ // which is what implements closeOnPress there.
43
+ asChild: Platform.OS === "web", style: StyleSheet.flatten([
26
44
  StyleSheet.absoluteFill,
27
45
  { backgroundColor: theme.colors.overlay },
28
46
  Platform.OS === "web" && { zIndex: 50 },
@@ -32,7 +50,8 @@ function DialogContent({ portalHost, style, children, ...props }) {
32
50
  borderColor: theme.colors.border,
33
51
  borderWidth: 1,
34
52
  borderRadius: spacing.radiusLg,
35
- padding: spacing.lg,
53
+ padding: spacing.dialogPadding,
54
+ gap: spacing.md,
36
55
  width: "100%",
37
56
  ...getShadowStyle("soft"),
38
57
  },
@@ -40,7 +59,7 @@ function DialogContent({ portalHost, style, children, ...props }) {
40
59
  ]), ...props, children: children }) }) }) }) }) }) }) }) }));
41
60
  }
42
61
  function DialogHeader({ children, style, ...props }) {
43
- return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs, marginBottom: spacing.md }, style]), ...props, children: children }));
62
+ return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
44
63
  }
45
64
  function DialogFooter({ children, style, ...props }) {
46
65
  return (_jsx(View, { style: StyleSheet.flatten([
@@ -48,7 +67,6 @@ function DialogFooter({ children, style, ...props }) {
48
67
  flexDirection: "row",
49
68
  justifyContent: "flex-end",
50
69
  gap: spacing.sm,
51
- marginTop: spacing.lg,
52
70
  },
53
71
  style,
54
72
  ]), ...props, children: children }));
@@ -107,7 +125,8 @@ function AlertDialogContent({ portalHost, style, children, ...props }) {
107
125
  borderColor: theme.colors.border,
108
126
  borderWidth: 1,
109
127
  borderRadius: spacing.radiusLg,
110
- padding: spacing.lg,
128
+ padding: spacing.dialogPadding,
129
+ gap: spacing.md,
111
130
  width: "100%",
112
131
  ...getShadowStyle("soft"),
113
132
  },
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Platform, ScrollView, View, } from "react-native";
4
- import { KeyboardController, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
4
+ import { KeyboardController, useKeyboardContext, useKeyboardState, } from "./keyboardController";
5
5
  import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
6
6
  import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
7
7
  /**
@@ -33,7 +33,7 @@ const createStyles = (theme) => StyleSheet.create({
33
33
  container: {
34
34
  alignItems: "center",
35
35
  justifyContent: "center",
36
- paddingVertical: spacing.xxl,
36
+ paddingVertical: spacing.xl,
37
37
  paddingHorizontal: spacing.lg,
38
38
  },
39
39
  bordered: {
@@ -41,7 +41,7 @@ const createStyles = (theme) => StyleSheet.create({
41
41
  borderStyle: "dashed",
42
42
  borderColor: theme.colors.border,
43
43
  borderRadius: spacing.radiusLg,
44
- padding: spacing.xl,
44
+ padding: spacing.lg,
45
45
  },
46
46
  iconWrapper: {
47
47
  width: MEDIA_SIZE,
@@ -16,8 +16,9 @@ export interface ItemProps {
16
16
  /**
17
17
  * Item
18
18
  *
19
- * Grouped-list row container: `spacing.md` padding and gap, a comfortable
20
- * 44pt min height, and an optional pressable scale interaction. Compose
19
+ * Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
20
+ * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
21
+ * and an optional pressable scale interaction. Compose
21
22
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
22
23
  * `ItemActions`.
23
24
  *
@@ -8,12 +8,13 @@ import { spacing } from "../constants/spacing.js";
8
8
  // Default ItemMedia footprint (see ItemMedia's `size` prop) — used to inset
9
9
  // the optional separator past the media slot without needing a context.
10
10
  const DEFAULT_MEDIA_SIZE = 40;
11
- const SEPARATOR_INSET = spacing.md + DEFAULT_MEDIA_SIZE + spacing.md;
11
+ const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGap;
12
12
  /**
13
13
  * Item
14
14
  *
15
- * Grouped-list row container: `spacing.md` padding and gap, a comfortable
16
- * 44pt min height, and an optional pressable scale interaction. Compose
15
+ * Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
16
+ * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
17
+ * and an optional pressable scale interaction. Compose
17
18
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
18
19
  * `ItemActions`.
19
20
  *
@@ -104,9 +105,10 @@ const styles = StyleSheet.create({
104
105
  row: {
105
106
  flexDirection: "row",
106
107
  alignItems: "center",
107
- minHeight: spacing.touchTarget,
108
- padding: spacing.md,
109
- gap: spacing.md,
108
+ minHeight: Platform.select({ web: spacing.rowMinHeight, default: spacing.touchTarget }),
109
+ paddingVertical: spacing.rowPaddingY,
110
+ paddingHorizontal: spacing.rowPaddingX,
111
+ gap: spacing.rowGap,
110
112
  },
111
113
  content: {
112
114
  flex: 1,
@@ -13,8 +13,10 @@ export declare function useKeyboardAvoidance(): boolean;
13
13
  /**
14
14
  * Package-level keyboard avoiding wrapper.
15
15
  *
16
- * Native uses `react-native-keyboard-controller` so screens can avoid the soft
17
- * keyboard with `automaticOffset`; web renders a plain `View`.
16
+ * Native uses `react-native-keyboard-controller` (through the platform-split
17
+ * `./keyboardController` module, which keeps the package out of the web bundle)
18
+ * so screens can avoid the soft keyboard with `automaticOffset`; web renders a
19
+ * plain `View`.
18
20
  */
19
21
  export declare function KeyboardAvoidingView({ children, style, behavior, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
20
22
  export {};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
3
  import { Platform, View, } from "react-native";
4
- import { KeyboardAvoidingView as NativeKeyboardAvoidingView, } from "react-native-keyboard-controller";
4
+ import { NativeKeyboardAvoidingView } from "./keyboardController";
5
5
  const KeyboardAvoidanceContext = createContext(false);
6
6
  export function useKeyboardAvoidance() {
7
7
  return useContext(KeyboardAvoidanceContext);
@@ -9,8 +9,10 @@ export function useKeyboardAvoidance() {
9
9
  /**
10
10
  * Package-level keyboard avoiding wrapper.
11
11
  *
12
- * Native uses `react-native-keyboard-controller` so screens can avoid the soft
13
- * keyboard with `automaticOffset`; web renders a plain `View`.
12
+ * Native uses `react-native-keyboard-controller` (through the platform-split
13
+ * `./keyboardController` module, which keeps the package out of the web bundle)
14
+ * so screens can avoid the soft keyboard with `automaticOffset`; web renders a
15
+ * plain `View`.
14
16
  */
15
17
  export function KeyboardAvoidingView({ children, style, behavior = Platform.OS === "ios" ? "padding" : "height", automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
16
18
  if (Platform.OS === "web") {
@@ -3,7 +3,7 @@ import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
3
3
  import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
4
4
  import { SafeAreaInsetsContext } from "react-native-safe-area-context";
5
5
  import { Icon } from "./Icon.js";
6
- import { useTheme } from "../hooks/useTheme.js";
6
+ import { useTheme, withAlpha } from "../hooks/useTheme.js";
7
7
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
9
  import { durations } from "../constants/motion.js";
@@ -158,26 +158,26 @@ export const Notification = () => {
158
158
  return {
159
159
  icon: "alert-circle",
160
160
  color: theme.colors.destructive,
161
- bgColor: theme.colors.destructive + "15",
161
+ bgColor: withAlpha(theme.colors.destructive, 0.08),
162
162
  };
163
163
  case "success":
164
164
  return {
165
165
  icon: "check-circle",
166
166
  color: theme.colors.success,
167
- bgColor: theme.colors.success + "15",
167
+ bgColor: withAlpha(theme.colors.success, 0.08),
168
168
  };
169
169
  case "warning":
170
170
  return {
171
171
  icon: "alert-triangle",
172
172
  color: theme.colors.warning,
173
- bgColor: theme.colors.warning + "15",
173
+ bgColor: withAlpha(theme.colors.warning, 0.08),
174
174
  };
175
175
  case "info":
176
176
  default:
177
177
  return {
178
178
  icon: "info",
179
179
  color: theme.colors.accent,
180
- bgColor: theme.colors.accent + "15",
180
+ bgColor: withAlpha(theme.colors.accent, 0.08),
181
181
  };
182
182
  }
183
183
  };
@@ -223,8 +223,8 @@ export const Notification = () => {
223
223
  ], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, size: "base", fontWeight: "semibold", style: { color: theme.colors.foreground }, numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, size: "sm", style: { color: theme.colors.mutedForeground }, numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
224
224
  styles.actionButton,
225
225
  {
226
- borderColor: theme.colors.primary + "30",
227
- backgroundColor: theme.colors.primary + "10",
226
+ borderColor: withAlpha(theme.colors.primary, 0.19),
227
+ backgroundColor: withAlpha(theme.colors.primary, 0.06),
228
228
  },
229
229
  pressed && styles.actionButtonPressed,
230
230
  ], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false, size: "sm", fontWeight: "semibold", style: { color: theme.colors.primary }, numberOfLines: 1, children: action.label }) })), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
151
151
  flexDirection: "row",
152
152
  alignItems: "center",
153
153
  gap: spacing.sm,
154
- minHeight: spacing.touchTarget,
154
+ minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
155
155
  },
156
156
  labelContainer: {
157
157
  flex: 1,
@@ -37,5 +37,5 @@ export function StatCard({ label, value, unit, change, icon, onPress, style }) {
37
37
  : change.direction === "down"
38
38
  ? theme.colors.destructive
39
39
  : theme.colors.textDim;
40
- return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.lg, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
40
+ return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.cardPadding, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
41
41
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Keyboard-controller indirection — web variant (inert stubs).
3
+ *
4
+ * `react-native-keyboard-controller` drags a large native animation runtime
5
+ * into every bundle that imports it, and none of that code can run on web.
6
+ * Components therefore import the keyboard API from this module instead of the
7
+ * package: Metro resolves `keyboardController.native.ts` (the real re-exports)
8
+ * on iOS/Android and this file on web, so the package never reaches the web
9
+ * bundle.
10
+ *
11
+ * Both variants are typed against the package's own types — type-only imports
12
+ * are erased at compile time and bundle nothing — so the two files stay
13
+ * interchangeable to the type checker. TypeScript always resolves this `.ts`
14
+ * variant for consumers, which is why the stubs must keep the full native
15
+ * signatures.
16
+ *
17
+ * Every stub here is a plain function that calls no hooks, so the `use*`
18
+ * exports are safe from any call site (rules of hooks can't be violated by a
19
+ * function that has no hook calls of its own).
20
+ */
21
+ import React from "react";
22
+ import { View } from "react-native";
23
+ import type { IKeyboardState, KeyboardAnimationContext, KeyboardAvoidingViewProps, KeyboardControllerModule } from "react-native-keyboard-controller";
24
+ /** No-op imperative module: there is no IME to dismiss or preload on web. */
25
+ export declare const KeyboardController: KeyboardControllerModule;
26
+ /**
27
+ * Applies `selector` to a permanently "keyboard hidden" state.
28
+ *
29
+ * @param selector - Picks the fields a caller needs from the keyboard state.
30
+ * @returns The selected value, or the whole state when no selector is given.
31
+ */
32
+ export declare function useKeyboardState<T = IKeyboardState>(selector?: (state: IKeyboardState) => T): T;
33
+ /**
34
+ * @returns A stable, empty keyboard context (no focused input layout on web).
35
+ */
36
+ export declare function useKeyboardContext(): KeyboardAnimationContext;
37
+ type NativeKeyboardAvoidingViewProps = KeyboardAvoidingViewProps & {
38
+ children?: React.ReactNode;
39
+ };
40
+ /**
41
+ * Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
42
+ * stay identical across platforms) and dropped, since the browser handles
43
+ * viewport resizing itself.
44
+ */
45
+ export declare function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }: NativeKeyboardAvoidingViewProps): React.CElement<import("react-native").ViewProps, View>;
46
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Keyboard-controller indirection — web variant (inert stubs).
3
+ *
4
+ * `react-native-keyboard-controller` drags a large native animation runtime
5
+ * into every bundle that imports it, and none of that code can run on web.
6
+ * Components therefore import the keyboard API from this module instead of the
7
+ * package: Metro resolves `keyboardController.native.ts` (the real re-exports)
8
+ * on iOS/Android and this file on web, so the package never reaches the web
9
+ * bundle.
10
+ *
11
+ * Both variants are typed against the package's own types — type-only imports
12
+ * are erased at compile time and bundle nothing — so the two files stay
13
+ * interchangeable to the type checker. TypeScript always resolves this `.ts`
14
+ * variant for consumers, which is why the stubs must keep the full native
15
+ * signatures.
16
+ *
17
+ * Every stub here is a plain function that calls no hooks, so the `use*`
18
+ * exports are safe from any call site (rules of hooks can't be violated by a
19
+ * function that has no hook calls of its own).
20
+ */
21
+ import React from "react";
22
+ import { View } from "react-native";
23
+ /** There is no software keyboard to observe on web, so state is constant. */
24
+ const HIDDEN_KEYBOARD_STATE = {
25
+ isVisible: false,
26
+ height: 0,
27
+ duration: 0,
28
+ timestamp: 0,
29
+ target: -1,
30
+ type: "default",
31
+ appearance: "light",
32
+ };
33
+ /**
34
+ * Inert keyboard context.
35
+ *
36
+ * The real context exposes animation values that only exist alongside the
37
+ * native module, so this is a minimal stand-in cast to the package's type: the
38
+ * only field consumers read on web-capable paths is `layout.value`. It is a
39
+ * module-level singleton because `DismissKeyboard` puts the context object in a
40
+ * `useCallback` dependency list — a fresh object per call would invalidate it
41
+ * on every render.
42
+ */
43
+ const INERT_KEYBOARD_CONTEXT = {
44
+ enabled: false,
45
+ layout: { value: null },
46
+ update: () => Promise.resolve(),
47
+ setKeyboardHandlers: () => () => undefined,
48
+ setInputHandlers: () => () => undefined,
49
+ setEnabled: () => undefined,
50
+ };
51
+ /** No-op imperative module: there is no IME to dismiss or preload on web. */
52
+ export const KeyboardController = {
53
+ setDefaultMode: () => undefined,
54
+ setInputMode: () => undefined,
55
+ preload: () => undefined,
56
+ dismiss: () => Promise.resolve(),
57
+ setFocusTo: () => undefined,
58
+ isVisible: () => false,
59
+ state: () => HIDDEN_KEYBOARD_STATE,
60
+ };
61
+ /**
62
+ * Applies `selector` to a permanently "keyboard hidden" state.
63
+ *
64
+ * @param selector - Picks the fields a caller needs from the keyboard state.
65
+ * @returns The selected value, or the whole state when no selector is given.
66
+ */
67
+ export function useKeyboardState(selector) {
68
+ return selector ? selector(HIDDEN_KEYBOARD_STATE) : HIDDEN_KEYBOARD_STATE;
69
+ }
70
+ /**
71
+ * @returns A stable, empty keyboard context (no focused input layout on web).
72
+ */
73
+ export function useKeyboardContext() {
74
+ return INERT_KEYBOARD_CONTEXT;
75
+ }
76
+ /**
77
+ * Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
78
+ * stay identical across platforms) and dropped, since the browser handles
79
+ * viewport resizing itself.
80
+ */
81
+ export function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }) {
82
+ // Dropped rather than forwarded so they never reach the DOM node.
83
+ void behavior;
84
+ void contentContainerStyle;
85
+ void keyboardVerticalOffset;
86
+ void automaticOffset;
87
+ void enabled;
88
+ return React.createElement(View, viewProps, children);
89
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Keyboard-controller indirection — native variant.
3
+ *
4
+ * Re-exports the real `react-native-keyboard-controller` API under the names the
5
+ * package's components import. See `keyboardController.ts` (the web variant)
6
+ * for why the components go through this module instead of importing the
7
+ * package directly.
8
+ */
9
+ export { KeyboardController, KeyboardAvoidingView as NativeKeyboardAvoidingView, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Keyboard-controller indirection — native variant.
3
+ *
4
+ * Re-exports the real `react-native-keyboard-controller` API under the names the
5
+ * package's components import. See `keyboardController.ts` (the web variant)
6
+ * for why the components go through this module instead of importing the
7
+ * package directly.
8
+ */
9
+ export { KeyboardController, KeyboardAvoidingView as NativeKeyboardAvoidingView, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
@@ -86,5 +86,39 @@ export interface Colors {
86
86
  light: Theme;
87
87
  dark: Theme;
88
88
  }
89
+ /**
90
+ * Raw hex/rgba theme colors, always literal strings on every platform. Use
91
+ * these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
92
+ * color parsing/math — and for generating the CSS variable definitions below.
93
+ */
94
+ export declare const rawThemeColors: {
95
+ light: ThemeColors;
96
+ dark: ThemeColors;
97
+ };
98
+ /**
99
+ * Resolves a theme color value to a literal color string. On web, theme
100
+ * colors are `var(--c-*)` references; this maps one back to the given
101
+ * scheme's raw hex/rgba. Literal inputs (including app override colors)
102
+ * pass through unchanged.
103
+ */
104
+ export declare function resolveRawColor(color: string, scheme: "light" | "dark"): string;
105
+ /**
106
+ * CSS custom-property definitions for every semantic theme color, for
107
+ * embedding in a web app's `+html.tsx` global `<style>`. Each token gets
108
+ * `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
109
+ * comma-separated RGB triplet, alpha stripped — `withAlpha` builds
110
+ * `rgba(var(--c-x-rgb), a)` from it).
111
+ *
112
+ * Light values are defined on `:root`; dark values apply under
113
+ * `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
114
+ * paint before any theme script stamps `data-theme`.
115
+ *
116
+ * Apps that override brand colors can pass those overrides so the emitted
117
+ * CSS matches their theme.
118
+ */
119
+ export declare function getThemeCssVariables(overrides?: {
120
+ light?: Partial<ThemeColors>;
121
+ dark?: Partial<ThemeColors>;
122
+ }): string;
89
123
  export declare const colors: Colors;
90
124
  export { palette };
@@ -1,3 +1,4 @@
1
+ import { Platform } from "react-native";
1
2
  import { navigationFonts } from "./fonts.js";
2
3
  /**
3
4
  * Raw color palette - use semantic colors in components instead
@@ -124,9 +125,98 @@ const darkTheme = {
124
125
  notification: palette.teal400,
125
126
  },
126
127
  };
127
- export const colors = {
128
- light: lightTheme,
129
- dark: darkTheme,
128
+ /**
129
+ * Raw hex/rgba theme colors, always literal strings on every platform. Use
130
+ * these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
131
+ * color parsing/math — and for generating the CSS variable definitions below.
132
+ */
133
+ export const rawThemeColors = {
134
+ light: lightTheme.colors,
135
+ dark: darkTheme.colors,
130
136
  };
137
+ const themeColorTokens = Object.keys(lightTheme.colors);
138
+ // `surfaceSunken` → `--c-surface-sunken`
139
+ function cssVarName(token) {
140
+ return `--c-${token.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
141
+ }
142
+ // Reverse lookup: "var(--c-surface-sunken)" → "surfaceSunken"
143
+ const varToToken = new Map(themeColorTokens.map((token) => [`var(${cssVarName(token)})`, token]));
144
+ /**
145
+ * Resolves a theme color value to a literal color string. On web, theme
146
+ * colors are `var(--c-*)` references; this maps one back to the given
147
+ * scheme's raw hex/rgba. Literal inputs (including app override colors)
148
+ * pass through unchanged.
149
+ */
150
+ export function resolveRawColor(color, scheme) {
151
+ const token = varToToken.get(color);
152
+ return token ? rawThemeColors[scheme][token] : color;
153
+ }
154
+ // "R, G, B" from "#RRGGBB", "#RGB", or "rgb(a)(R, G, B[, A])" (alpha dropped).
155
+ function toRgbTriplet(value) {
156
+ const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
157
+ if (rgbaMatch)
158
+ return `${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}`;
159
+ let hex = value.replace(/^#/, "");
160
+ if (hex.length === 3)
161
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
162
+ const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
163
+ if (!match)
164
+ return null;
165
+ return `${parseInt(match[1], 16)}, ${parseInt(match[2], 16)}, ${parseInt(match[3], 16)}`;
166
+ }
167
+ /**
168
+ * CSS custom-property definitions for every semantic theme color, for
169
+ * embedding in a web app's `+html.tsx` global `<style>`. Each token gets
170
+ * `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
171
+ * comma-separated RGB triplet, alpha stripped — `withAlpha` builds
172
+ * `rgba(var(--c-x-rgb), a)` from it).
173
+ *
174
+ * Light values are defined on `:root`; dark values apply under
175
+ * `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
176
+ * paint before any theme script stamps `data-theme`.
177
+ *
178
+ * Apps that override brand colors can pass those overrides so the emitted
179
+ * CSS matches their theme.
180
+ */
181
+ export function getThemeCssVariables(overrides) {
182
+ const declarations = (scheme) => themeColorTokens
183
+ .map((token) => {
184
+ const value = overrides?.[scheme]?.[token] ?? rawThemeColors[scheme][token];
185
+ const name = cssVarName(token);
186
+ const rgb = toRgbTriplet(value);
187
+ return `${name}: ${value};${rgb ? ` ${name}-rgb: ${rgb};` : ""}`;
188
+ })
189
+ .join("\n ");
190
+ return `
191
+ :root {
192
+ ${declarations("light")}
193
+ }
194
+
195
+ @media (prefers-color-scheme: dark) {
196
+ html:not([data-theme]) {
197
+ ${declarations("dark")}
198
+ }
199
+ }
200
+
201
+ html[data-theme="dark"] {
202
+ ${declarations("dark")}
203
+ }
204
+ `;
205
+ }
206
+ // On web, semantic theme colors resolve to CSS custom properties so the
207
+ // build-time exported HTML is theme-agnostic: the first frame paints in the
208
+ // visitor's scheme purely from CSS, before any JS runs. Native keeps literal
209
+ // values (no CSS engine). The `navigation` maps stay literal on both
210
+ // platforms: @react-navigation internals color-parse their theme values.
211
+ const webVarColors = Object.fromEntries(themeColorTokens.map((token) => [token, `var(${cssVarName(token)})`]));
212
+ export const colors = Platform.OS === "web"
213
+ ? {
214
+ light: { ...lightTheme, colors: { ...webVarColors } },
215
+ dark: { ...darkTheme, colors: { ...webVarColors } },
216
+ }
217
+ : {
218
+ light: lightTheme,
219
+ dark: darkTheme,
220
+ };
131
221
  // Export palette for rare one-off cases
132
222
  export { palette };
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Spacing Constants
3
3
  *
4
- * A consistent spacing scale based on an 8px base unit.
5
- * Use these constants throughout the app for consistent spacing.
4
+ * A consistent spacing scale based on an 8px base unit, plus a small set of
5
+ * density tokens that components reach for when composing rows, surfaces, and
6
+ * screens. Tune the density tokens to shift the whole library's feel without
7
+ * touching the base scale.
6
8
  */
7
9
  export declare const spacing: {
8
10
  readonly base: 8;
9
11
  readonly xxs: 2;
10
12
  readonly xs: 4;
11
13
  readonly sm: 8;
14
+ readonly smd: 12;
12
15
  readonly md: 16;
16
+ readonly mdl: 20;
13
17
  readonly lg: 24;
14
18
  readonly xl: 32;
15
19
  readonly xxl: 48;
@@ -17,12 +21,18 @@ export declare const spacing: {
17
21
  readonly gutter: 16;
18
22
  readonly gutterVertical: 24;
19
23
  readonly screenPadding: 16;
24
+ readonly sectionSpacing: 24;
25
+ readonly rowPaddingY: 10;
26
+ readonly rowPaddingX: 16;
27
+ readonly rowGap: 12;
28
+ readonly rowMinHeight: 40;
29
+ readonly formRowMinHeight: 32;
30
+ readonly cardPadding: 16;
31
+ readonly dialogPadding: 20;
32
+ readonly touchTarget: 44;
20
33
  readonly buttonPadding: 10;
21
34
  readonly inputPadding: 10;
22
- readonly cardPadding: 16;
23
- readonly sectionSpacing: 32;
24
35
  readonly listItemSpacing: 8;
25
- readonly touchTarget: 44;
26
36
  readonly radiusNone: 0;
27
37
  readonly radiusXs: 4;
28
38
  readonly radiusSm: 8;
@@ -37,5 +47,5 @@ export declare const spacing: {
37
47
  readonly iconLg: 32;
38
48
  readonly iconXl: 48;
39
49
  };
40
- export declare const base: 8, xxs: 2, xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, buttonPadding: 10, inputPadding: 10, cardPadding: 16, sectionSpacing: 32, listItemSpacing: 8, touchTarget: 44, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
50
+ export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
41
51
  export declare const space: (multiplier: number) => number;
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Spacing Constants
3
3
  *
4
- * A consistent spacing scale based on an 8px base unit.
5
- * Use these constants throughout the app for consistent spacing.
4
+ * A consistent spacing scale based on an 8px base unit, plus a small set of
5
+ * density tokens that components reach for when composing rows, surfaces, and
6
+ * screens. Tune the density tokens to shift the whole library's feel without
7
+ * touching the base scale.
6
8
  */
7
9
  export const spacing = {
8
10
  // Base unit: 8px
@@ -11,7 +13,9 @@ export const spacing = {
11
13
  xxs: 2, // 2px - Minimal spacing
12
14
  xs: 4, // 4px - Extra small
13
15
  sm: 8, // 8px - Small (1 unit)
16
+ smd: 12, // 12px - Between small and medium (1.5 units); compact gaps and row padding
14
17
  md: 16, // 16px - Medium (2 units)
18
+ mdl: 20, // 20px - Between medium and large (2.5 units); dialog and sheet padding
15
19
  lg: 24, // 24px - Large (3 units)
16
20
  xl: 32, // 32px - Extra large (4 units)
17
21
  xxl: 48, // 48px - 2x extra large (6 units)
@@ -20,13 +24,20 @@ export const spacing = {
20
24
  gutter: 16, // Standard horizontal gutter
21
25
  gutterVertical: 24, // Standard vertical spacing
22
26
  screenPadding: 16, // Default screen edge padding
27
+ sectionSpacing: 24, // Space between major sections / grouped lists
28
+ // Density tokens — rows, surfaces, overlays
29
+ rowPaddingY: 10, // Vertical padding for list / settings rows
30
+ rowPaddingX: 16, // Horizontal padding for list / settings rows
31
+ rowGap: 12, // Gap between media, content, and actions in a row
32
+ rowMinHeight: 40, // Visual min height for rows on pointer surfaces (web)
33
+ formRowMinHeight: 32, // Visual min height for checkbox / radio rows on web
34
+ cardPadding: 16, // Default card / stat card padding
35
+ dialogPadding: 20, // Default dialog / alert dialog padding
36
+ touchTarget: 44, // Minimum comfortable native touch target
23
37
  // Component-specific
24
38
  buttonPadding: 10, // Default button padding
25
39
  inputPadding: 10, // Default input padding
26
- cardPadding: 16, // Default card padding
27
- sectionSpacing: 32, // Space between major sections
28
40
  listItemSpacing: 8, // Space between list items
29
- touchTarget: 44, // Minimum comfortable native touch target
30
41
  // Border radius — shadcn-inspired scale (radiusMd = 12px default)
31
42
  radiusNone: 0,
32
43
  radiusXs: 4,
@@ -44,6 +55,6 @@ export const spacing = {
44
55
  iconXl: 48,
45
56
  };
46
57
  // Export individual constants for convenience
47
- export const { base, xxs, xs, sm, md, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, buttonPadding, inputPadding, cardPadding, sectionSpacing, listItemSpacing, touchTarget, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
58
+ export const { base, xxs, xs, sm, smd, md, mdl, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, sectionSpacing, rowPaddingY, rowPaddingX, rowGap, rowMinHeight, formRowMinHeight, cardPadding, dialogPadding, touchTarget, buttonPadding, inputPadding, listItemSpacing, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
48
59
  // Helper function to multiply base spacing
49
60
  export const space = (multiplier) => spacing.base * multiplier;
@@ -29,11 +29,13 @@ type WindowDimensions = {
29
29
  * Provides a consistent way to access window dimensions and screen size
30
30
  * information across mobile and web.
31
31
  *
32
- * On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
33
- * window can't be read while the HTML shell is rendered in Node at export
34
- * time, and reading it during the browser's first render would disagree with
35
- * that markup. A post-mount effect swaps in the real viewport and follows
36
- * resize from there.
32
+ * On web the first render can't read the window: server-side there is no DOM,
33
+ * and reading it during the browser's first render would disagree with the
34
+ * markup it hydrates. The width comes from `SsrViewportContext` when the host
35
+ * provides a per-request value (server render + first client render must
36
+ * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
37
+ * A post-mount effect swaps in the real viewport and follows resize from
38
+ * there.
37
39
  */
38
40
  export declare const useDimensions: () => WindowDimensions;
39
41
  export {};
@@ -1,5 +1,6 @@
1
- import { useEffect, useState } from "react";
1
+ import { use, useEffect, useState } from "react";
2
2
  import { Platform, useWindowDimensions } from "react-native";
3
+ import { SsrViewportContext } from "../state/SsrViewportContext.js";
3
4
  /**
4
5
  * Viewport the first web render is computed from, before the real window can
5
6
  * be read. Desktop is chosen because:
@@ -33,24 +34,41 @@ const calculateDimensionFlags = (width, height) => {
33
34
  isLargeScreen: width > SCREEN_SIZES.MEDIUM,
34
35
  };
35
36
  };
37
+ // Persist the real viewport width as a cookie on first mount so subsequent
38
+ // SSR requests can render at the user's actual layout (no reflow on repeat
39
+ // visits). The server reads this cookie via resolveSsrViewportWidth in
40
+ // server/lib/ssrViewport.ts.
41
+ const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
42
+ const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
43
+ function writeViewportCookie(width) {
44
+ if (typeof document === "undefined")
45
+ return;
46
+ // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
47
+ // every pixel of horizontal movement.
48
+ const rounded = Math.round(width / 10) * 10;
49
+ document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
50
+ }
36
51
  /**
37
52
  * Provides a consistent way to access window dimensions and screen size
38
53
  * information across mobile and web.
39
54
  *
40
- * On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
41
- * window can't be read while the HTML shell is rendered in Node at export
42
- * time, and reading it during the browser's first render would disagree with
43
- * that markup. A post-mount effect swaps in the real viewport and follows
44
- * resize from there.
55
+ * On web the first render can't read the window: server-side there is no DOM,
56
+ * and reading it during the browser's first render would disagree with the
57
+ * markup it hydrates. The width comes from `SsrViewportContext` when the host
58
+ * provides a per-request value (server render + first client render must
59
+ * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
60
+ * A post-mount effect swaps in the real viewport and follows resize from
61
+ * there.
45
62
  */
46
63
  export const useDimensions = () => {
47
64
  const isWeb = Platform.OS === "web";
65
+ const ssrWidth = use(SsrViewportContext);
48
66
  // Native reads come from useWindowDimensions, which subscribes to rotation /
49
67
  // split-screen / resize and tears the listener down for us — no manual
50
68
  // Dimensions.addEventListener to leak. On web we ignore it and drive layout
51
- // from the default frame + the resize listener below.
69
+ // from the seeded frame + the resize listener below.
52
70
  const native = useWindowDimensions();
53
- const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
71
+ const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
54
72
  // Web: read the real viewport after mount and follow resize events. Keeping
55
73
  // this in an effect (not render) keeps the first render identical to the
56
74
  // exported markup it hydrates.
@@ -59,6 +77,7 @@ export const useDimensions = () => {
59
77
  return;
60
78
  const syncFromWindow = () => {
61
79
  setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
80
+ writeViewportCookie(window.innerWidth);
62
81
  };
63
82
  syncFromWindow();
64
83
  window.addEventListener("resize", syncFromWindow);
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Registers the Feather icon font in expo-font's server store during
3
+ * export-time rendering (`expo export` renders every route's HTML shell in
4
+ * Node, where the module-scope client load above is guarded off).
5
+ *
6
+ * Must be called from a **render body** (see `useResources` below): expo-font
7
+ * scopes its server store per render context, so a module-scope registration
8
+ * lands in at most one route's shell. Registering during render means every
9
+ * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
10
+ * without it the shell contains an EMPTY icon text node while the client's
11
+ * first render (font pre-loaded above) contains the glyph, and React throws
12
+ * hydration error #418 on every cold load and regenerates the tree.
13
+ *
14
+ * No-op on native and on client web (both already covered: native by the
15
+ * effect below, client web by the module-scope call above).
16
+ */
17
+ export declare function ensureIconFontRegistered(): void;
1
18
  interface LoadResourcesResult {
2
19
  loaded: boolean;
3
20
  error: Error | null;
@@ -15,6 +15,32 @@ import { useThemeStore } from "../state/themeStore.js";
15
15
  if (typeof window !== "undefined") {
16
16
  void Font.loadAsync(Feather.font);
17
17
  }
18
+ /**
19
+ * Registers the Feather icon font in expo-font's server store during
20
+ * export-time rendering (`expo export` renders every route's HTML shell in
21
+ * Node, where the module-scope client load above is guarded off).
22
+ *
23
+ * Must be called from a **render body** (see `useResources` below): expo-font
24
+ * scopes its server store per render context, so a module-scope registration
25
+ * lands in at most one route's shell. Registering during render means every
26
+ * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
27
+ * without it the shell contains an EMPTY icon text node while the client's
28
+ * first render (font pre-loaded above) contains the glyph, and React throws
29
+ * hydration error #418 on every cold load and regenerates the tree.
30
+ *
31
+ * No-op on native and on client web (both already covered: native by the
32
+ * effect below, client web by the module-scope call above).
33
+ */
34
+ export function ensureIconFontRegistered() {
35
+ if (Platform.OS !== "web" || typeof window !== "undefined")
36
+ return;
37
+ if (Font.isLoaded("feather"))
38
+ return;
39
+ // Synchronous on server-web (expo-font routes to registerStaticFont). No
40
+ // `void`/try-catch: a throw here means we're outside a render scope, which
41
+ // must be loud.
42
+ Font.loadAsync(Feather.font);
43
+ }
18
44
  // The four static Inter weights StyledText's native family keys point at
19
45
  // (see constants/fonts.ts). Native-only: web never renders these family names
20
46
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -68,6 +94,9 @@ function ensureWebFontStylesheet() {
68
94
  * un-download Inter. The Feather icon font always loads.
69
95
  */
70
96
  export const useResources = () => {
97
+ // Export-time renders need the icon font registered per render scope; the
98
+ // module-scope call only reaches the client. See ensureIconFontRegistered.
99
+ ensureIconFontRegistered();
71
100
  const [loaded, setLoaded] = useState(false);
72
101
  const [error, setError] = useState(null);
73
102
  useEffect(() => {
@@ -39,6 +39,24 @@ export declare function useTheme(): ExtendedColorScheme & {
39
39
  setTheme: (theme: "system" | "light" | "dark") => void;
40
40
  currentTheme: "system" | "light" | "dark";
41
41
  };
42
+ /**
43
+ * Generates an alpha-modified version of a color
44
+ * @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
45
+ * @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
46
+ * @returns rgba color string with the specified alpha
47
+ *
48
+ * On web, theme colors are `var(--c-*)` references. Those rewrite onto the
49
+ * companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
50
+ * result is pure CSS that re-themes with the variable — usable from
51
+ * module-scope style factories, which run once for both schemes.
52
+ *
53
+ * The result is wrapped in `var(--c-unset, …)` — a custom property that is
54
+ * never defined, so browsers always use the fallback. react-native-web's
55
+ * style compiler only passes a color value through verbatim when it starts
56
+ * with `var(`; a bare `rgba(var(…))` fails its color parser and the
57
+ * declaration is silently dropped.
58
+ */
59
+ export declare function withAlpha(color: string, alpha: number): string;
42
60
  /**
43
61
  * Style factory context passed to the createStyles callback
44
62
  */
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
- import { colors } from "../constants/colors.js";
2
+ import { colors, resolveRawColor } from "../constants/colors.js";
3
3
  import { Platform, StyleSheet } from "react-native";
4
4
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
5
  import { useThemeColorScope } from "../state/themeColorScope.js";
@@ -170,10 +170,12 @@ export function useTheme() {
170
170
  boxShadow: `0 0 0 ${offset}px ${theme.colors.background}, 0 0 0 ${offset + 2}px ${theme.colors.ring}`,
171
171
  };
172
172
  }, [theme.colors.background, theme.colors.ring]);
173
- // Helper to calculate contrast ratio between two colors
173
+ // Helper to calculate contrast ratio between two colors.
174
+ // On web theme colors are `var(--c-*)` strings, which cannot be parsed —
175
+ // resolve them to the active scheme's literal value first.
174
176
  const getContrastRatio = useCallback((color1, color2) => {
175
- const rgb1 = parseColor(color1);
176
- const rgb2 = parseColor(color2);
177
+ const rgb1 = parseColor(resolveRawColor(color1, effectiveScheme));
178
+ const rgb2 = parseColor(resolveRawColor(color2, effectiveScheme));
177
179
  if (!rgb1 || !rgb2) {
178
180
  console.error("Invalid colors for contrast ratio:", color1, color2);
179
181
  return 1; // Default to minimum contrast
@@ -181,13 +183,25 @@ export function useTheme() {
181
183
  const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
182
184
  const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
183
185
  return calculateContrastRatio(lum1, lum2);
184
- }, []);
186
+ }, [effectiveScheme]);
185
187
  // Cached version of getContrastingColor for performance
186
- // Uses module-level cache to prevent memory leak
188
+ // Uses module-level cache to prevent memory leak. `var(--c-*)` inputs are
189
+ // resolved per scheme BEFORE the cache so keys stay scheme-specific, and
190
+ // the winning candidate is returned as originally passed (a `var()` input
191
+ // stays a `var()` output, so the picked color keeps re-theming via CSS).
187
192
  const getCachedContrastingColor = useCallback((backgroundColor, color1, color2) => {
188
- const cacheKey = `${backgroundColor}-${color1}-${color2}`;
189
- return getCachedOrCompute(cacheKey, () => getBetterContrast(backgroundColor, color1, color2));
190
- }, []);
193
+ const bg = resolveRawColor(backgroundColor, effectiveScheme);
194
+ const c1 = color1 === undefined ? undefined : resolveRawColor(color1, effectiveScheme);
195
+ const c2 = color2 === undefined ? undefined : resolveRawColor(color2, effectiveScheme);
196
+ const cacheKey = `${bg}-${c1}-${c2}`;
197
+ const winner = getCachedOrCompute(cacheKey, () => getBetterContrast(bg, c1, c2));
198
+ if (color1 !== undefined && winner === c1)
199
+ return color1;
200
+ if (color2 !== undefined && winner === c2)
201
+ return color2;
202
+ return winner;
203
+ }, [effectiveScheme]);
204
+ const getResolvedTextColorForBackground = useCallback((backgroundColor) => getTextColorForBackground(resolveRawColor(backgroundColor, effectiveScheme)), [effectiveScheme]);
191
205
  return useMemo(() => ({
192
206
  theme,
193
207
  scheme: theme.dark ? "dark" : "light",
@@ -197,13 +211,14 @@ export function useTheme() {
197
211
  setTheme,
198
212
  currentTheme: userTheme,
199
213
  getContrastingColor: getCachedContrastingColor,
200
- getTextColorForBackground,
214
+ getTextColorForBackground: getResolvedTextColorForBackground,
201
215
  withAlpha,
202
216
  getContrastRatio,
203
217
  }), [
204
218
  getCachedContrastingColor,
205
219
  getContrastRatio,
206
220
  getFocusRingStyle,
221
+ getResolvedTextColorForBackground,
207
222
  getShadowStyle,
208
223
  setTheme,
209
224
  theme,
@@ -326,11 +341,26 @@ function getTextColorForBackground(backgroundColor) {
326
341
  }
327
342
  /**
328
343
  * Generates an alpha-modified version of a color
329
- * @param color - The color to modify (hex or rgba)
330
- * @param alpha - Alpha value between 0 and 1
344
+ * @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
345
+ * @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
331
346
  * @returns rgba color string with the specified alpha
347
+ *
348
+ * On web, theme colors are `var(--c-*)` references. Those rewrite onto the
349
+ * companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
350
+ * result is pure CSS that re-themes with the variable — usable from
351
+ * module-scope style factories, which run once for both schemes.
352
+ *
353
+ * The result is wrapped in `var(--c-unset, …)` — a custom property that is
354
+ * never defined, so browsers always use the fallback. react-native-web's
355
+ * style compiler only passes a color value through verbatim when it starts
356
+ * with `var(`; a bare `rgba(var(…))` fails its color parser and the
357
+ * declaration is silently dropped.
332
358
  */
333
- function withAlpha(color, alpha) {
359
+ export function withAlpha(color, alpha) {
360
+ const varMatch = /^var\((--c-[a-z0-9-]+)\)$/.exec(color);
361
+ if (varMatch) {
362
+ return `var(--c-unset, rgba(var(${varMatch[1]}-rgb), ${alpha}))`;
363
+ }
334
364
  const rgb = parseColor(color);
335
365
  if (!rgb) {
336
366
  console.error("Invalid color for alpha:", color);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
3
+ * server render and the browser's FIRST render, so a server that detected a
4
+ * phone lays the tree out at phone width and hydration matches byte-for-byte.
5
+ *
6
+ * `null` (the default, when no provider is mounted) means "no per-request
7
+ * signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
8
+ * host app is expected to provide the same value on the server and on the
9
+ * client's first render — both derived from signals both sides can read
10
+ * (cookie / User-Agent), never from `window`.
11
+ */
12
+ export declare const SsrViewportContext: import("react").Context<number | null>;
@@ -0,0 +1,13 @@
1
+ import { createContext } from "react";
2
+ /**
3
+ * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
4
+ * server render and the browser's FIRST render, so a server that detected a
5
+ * phone lays the tree out at phone width and hydration matches byte-for-byte.
6
+ *
7
+ * `null` (the default, when no provider is mounted) means "no per-request
8
+ * signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
9
+ * host app is expected to provide the same value on the server and on the
10
+ * client's first render — both derived from signals both sides can read
11
+ * (cookie / User-Agent), never from `window`.
12
+ */
13
+ export const SsrViewportContext = createContext(null);
@@ -1,4 +1,5 @@
1
1
  export * from "./globalUIStore";
2
2
  export * from "./notify";
3
+ export * from "./SsrViewportContext";
3
4
  export * from "./themeStore";
4
5
  export * from "./themeColorScope";
@@ -1,4 +1,5 @@
1
1
  export * from "./globalUIStore.js";
2
2
  export * from "./notify.js";
3
+ export * from "./SsrViewportContext.js";
3
4
  export * from "./themeStore.js";
4
5
  export * from "./themeColorScope.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -83,7 +83,7 @@
83
83
  "scripts": {
84
84
  "typecheck": "tsc --noEmit -p tsconfig.json",
85
85
  "test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false && bun run check:forbidden-imports",
86
- "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-ui-package-esm.mjs && bun run check:forbidden-imports",
86
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-package-esm.mjs ui && bun run check:forbidden-imports",
87
87
  "check:forbidden-imports": "node ../../scripts/check-ui-forbidden-imports.mjs",
88
88
  "publish:dry-run": "bun pm pack --dry-run"
89
89
  },
@@ -98,7 +98,7 @@
98
98
  "@rn-primitives/dropdown-menu": "~1.5.2",
99
99
  "@rn-primitives/label": "~1.5.2",
100
100
  "@rn-primitives/popover": "~1.5.2",
101
- "@rn-primitives/portal": "~1.5.2",
101
+ "@rn-primitives/portal": "~1.5.3",
102
102
  "@rn-primitives/radio-group": "~1.5.2",
103
103
  "@rn-primitives/select": "~1.5.2",
104
104
  "@rn-primitives/separator": "~1.5.2",
@@ -125,7 +125,7 @@
125
125
  "zustand": ">=5.0.0 <6.0.0"
126
126
  },
127
127
  "devDependencies": {
128
- "@types/react": "~19.2.17",
128
+ "@types/react": "~19.2.18",
129
129
  "typescript": "~6.0.3"
130
130
  }
131
131
  }