@mrmeg/expo-ui 0.21.0 → 0.22.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,58 @@
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.22.0]
7
+
8
+ ### Added
9
+
10
+ - **CSS-variable theming on web.** On web, every semantic theme color
11
+ (`theme.colors.*`) now resolves to a CSS custom property
12
+ (`var(--c-<kebab-token>)`) instead of a literal hex string, so styles built
13
+ from the theme — including the HTML shells baked at export time — re-theme
14
+ purely in CSS when `html[data-theme]` changes. Native platforms keep
15
+ literal values. New exports from `constants`:
16
+ - `getThemeCssVariables(overrides?)` — the `--c-*` (and companion
17
+ `--c-*-rgb` triplet) definitions per scheme, for embedding in an app's
18
+ `+html.tsx` global `<style>`. Accepts per-scheme brand-color overrides.
19
+ - `rawThemeColors.light/.dark` — the literal hex/rgba maps on every
20
+ platform, for sinks that cannot take `var()` (e.g.
21
+ `<meta name="theme-color">`).
22
+ - `resolveRawColor(color, scheme)` — maps a `var(--c-*)` theme color back
23
+ to the given scheme's literal value; literal inputs pass through.
24
+ - **`withAlpha` is exported standalone** (in addition to the `useTheme`
25
+ return) so module-scope style factories can import it. It understands
26
+ `var(--c-*)` inputs and rewrites them onto the `--c-*-rgb` triplet
27
+ (`var(--c-unset, rgba(var(--c-x-rgb), a))` — the never-defined outer
28
+ variable makes react-native-web's compiler pass the value through), staying
29
+ pure CSS that re-themes with the variable. Hex-suffix alpha concatenation
30
+ (`theme.colors.x + "15"`) does not work with `var()` colors — use
31
+ `withAlpha(theme.colors.x, 0.08)`.
32
+
33
+ ### Changed
34
+
35
+ - The contrast helpers from `useTheme` (`getContrastingColor`,
36
+ `getContrastRatio`, `getTextColorForBackground`) resolve `var(--c-*)`
37
+ inputs to the active scheme's literal value before parsing, and
38
+ `getContrastingColor` returns the winning candidate as originally passed
39
+ (a `var()` input stays a `var()` output).
40
+ - The React Navigation theme maps (`colors.<scheme>.navigation`) stay
41
+ literal on web as well as native: @react-navigation internals parse their
42
+ theme colors with a color library, which cannot take `var()` references.
43
+
44
+ ## [0.21.1]
45
+
46
+ ### Fixed
47
+
48
+ - **Exported web HTML renders icon glyphs, fixing React hydration error
49
+ #418 on every cold load.** `useResources` registers the Feather icon font
50
+ per render scope during `expo export`'s Node rendering
51
+ (`ensureIconFontRegistered`), so the static shell emits the glyph and its
52
+ font styles. Previously the shell contained an empty icon text node while
53
+ the client's first render (font pre-loaded at module scope) contained the
54
+ glyph — React 19 threw #418 and regenerated the tree on every cold load.
55
+ The same mechanism existed during the request-time SSR era and was removed
56
+ with it; static export needs it too.
57
+
6
58
  ## [0.21.0]
7
59
 
8
60
  ### Added
@@ -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";
@@ -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
  /**
@@ -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 }) })] }) }));
@@ -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 };
@@ -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.22.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -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
  }