@mrmeg/expo-ui 0.15.0 → 0.16.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,64 @@
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.16.0]
7
+
8
+ ### Added
9
+
10
+ - **`setFonts` — font injection, mirroring `setColors`.** A host app can now
11
+ forward its own bundled faces to the package:
12
+
13
+ ```ts
14
+ useThemeStore.getState().setFonts({
15
+ families: { sansSerif: { light: "…", regular: "…", medium: "…",
16
+ semibold: "…", bold: "…" } },
17
+ webWeightStrategy: "family",
18
+ });
19
+ ```
20
+
21
+ Overrides are partial and per-group — pass `sansSerif` alone and `serif`
22
+ keeps the package default. `setFonts({})` clears back to the defaults.
23
+ `StyledText` subscribes to the store, so a call after mount re-renders
24
+ rather than leaving stale families on screen.
25
+
26
+ - **`webWeightStrategy` — `"numeric" | "family"`.** Declares how a family map
27
+ expresses weight. `"numeric"` (the package default on web) assumes one
28
+ multi-weight CSS family where a numeric `fontWeight` selects the
29
+ `@font-face` variant. `"family"` means each weight is its own
30
+ separately-registered single-face family, so the family name alone carries
31
+ the weight and `StyledText` suppresses the numeric `fontWeight`. Native is
32
+ always `"family"`, unchanged.
33
+
34
+ Apps loading per-weight faces through `expo-font` need `"family"` on web:
35
+ `expo-font` registers each file as its own family there too, so the previous
36
+ always-numeric behaviour synthesised a second layer of bold on top of an
37
+ already-bold face.
38
+
39
+ - Exported `FontFamilyMap`, `FontFamilyWeight`, `FontOverrides`,
40
+ `FontWeightStrategy`, and `defaultWebWeightStrategy` from `constants/fonts`.
41
+
42
+ - **`createThemedStyles` — SSR-safe themed style registration**, exported from
43
+ `lib`. Wraps a `(theme: Theme) => styles` factory so both base themes are
44
+ registered with react-native-web at module scope — before expo-router
45
+ snapshots the server-side stylesheet — instead of lazily during render via
46
+ `useMemo`, which left SSR HTML referencing class names with no rules
47
+ (unstyled first paint until hydration). Override themes are cached per theme
48
+ object identity. The package's own components (Badge, Button, Card,
49
+ EmptyState, InputOTP, Label, Notification, Skeleton, TextInput) now use it.
50
+
51
+ ### Why
52
+
53
+ `fontFamilies` was a module-level `const` with no injection point, so the only
54
+ way for a consumer to use its own type was to patch `dist/` inside
55
+ `node_modules`. Bun keys `patchedDependencies` on an exact `name@version`, so
56
+ those patches stop applying — silently, with no warning — the moment the
57
+ package version is bumped. Two consumer apps hit exactly that and shipped
58
+ fallback fonts without noticing. Colors already had `setColors` for this
59
+ reason; fonts now match.
60
+
61
+ Fully backward compatible: with no `setFonts` call, font resolution is
62
+ identical to 0.15.0.
63
+
6
64
  ## [0.15.0]
7
65
 
8
66
  ### Added
package/README.md CHANGED
@@ -163,6 +163,50 @@ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
163
163
  </ThemeColorScope>
164
164
  ```
165
165
 
166
+ ### Font overrides
167
+
168
+ The package bundles Inter (sans) and Georgia (serif). Most apps ship their own
169
+ faces — forward them with `setFonts`, the font counterpart to `setColors`.
170
+ Call it once at startup, after your fonts are registered:
171
+
172
+ ```tsx
173
+ import { useThemeStore } from "@mrmeg/expo-ui/state";
174
+
175
+ useThemeStore.getState().setFonts({
176
+ families: {
177
+ sansSerif: {
178
+ light: "HankenGrotesk_300Light",
179
+ regular: "HankenGrotesk_400Regular",
180
+ medium: "HankenGrotesk_500Medium",
181
+ semibold: "HankenGrotesk_600SemiBold",
182
+ bold: "HankenGrotesk_700Bold",
183
+ },
184
+ },
185
+ webWeightStrategy: "family",
186
+ });
187
+ ```
188
+
189
+ Overrides are partial and per-group: pass `sansSerif` alone and `serif` keeps
190
+ the package default. `setFonts({})` clears back to the defaults.
191
+
192
+ **Pick the right `webWeightStrategy`** — this is the one non-obvious part:
193
+
194
+ | Strategy | Use when | Effect |
195
+ |---|---|---|
196
+ | `"numeric"` (default) | You ship **one multi-weight CSS family** (a variable font, or Google Fonts' single `"Inter"` family) | A numeric `fontWeight` selects the `@font-face` variant |
197
+ | `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
198
+
199
+ If you load fonts through `expo-font` / `@expo-google-fonts`, choose
200
+ `"family"`. `expo-font` registers each weight as its own single-face family on
201
+ web as well as native, so emitting a numeric weight on top makes the browser
202
+ synthesise a *second* layer of bold over an already-bold face.
203
+
204
+ Native always behaves as `"family"` regardless of this setting.
205
+
206
+ > Prefer `setFonts` over patching `node_modules`. Bun keys
207
+ > `patchedDependencies` to an exact `name@version`, so a font patch silently
208
+ > stops applying the next time you bump the package — with no warning.
209
+
166
210
  Use `StyledText` for theme-aware text:
167
211
 
168
212
  ```tsx
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useMemo } from "react";
2
+ import React from "react";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { StyledText } from "./StyledText.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
7
8
  /**
8
9
  * Badge Component
9
10
  *
@@ -19,7 +20,7 @@ import { StyledText } from "./StyledText.js";
19
20
  */
20
21
  function Badge({ children, text, variant = "default", style: styleOverride }) {
21
22
  const { theme } = useTheme();
22
- const styles = useMemo(() => createStyles(theme), [theme]);
23
+ const styles = themedStyles(theme);
23
24
  const badgeContent = text ?? children;
24
25
  const textStyle = [
25
26
  styles.text,
@@ -74,4 +75,5 @@ const createStyles = (theme) => StyleSheet.create({
74
75
  userSelect: "none",
75
76
  },
76
77
  });
78
+ const themedStyles = createThemedStyles(createStyles);
77
79
  export { Badge };
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { use, useCallback, useMemo, useState } from "react";
2
+ import { use, useCallback, useState } from "react";
3
3
  import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { StyledText } from "./StyledText.js";
@@ -9,6 +9,7 @@ import { fontFamilies } from "../constants/fonts.js";
9
9
  import { palette } from "../constants/colors.js";
10
10
  import { useTheme } from "../hooks/useTheme.js";
11
11
  import { useScalePress } from "../hooks/useScalePress.js";
12
+ import { createThemedStyles } from "../lib/themedStyles.js";
12
13
  const SIZE_CONFIGS = {
13
14
  sm: {
14
15
  paddingVertical: spacing.xxs,
@@ -72,7 +73,7 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
72
73
  function ButtonRoot(props) {
73
74
  const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
74
75
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
75
- const styles = useMemo(() => createStyles(theme, size), [theme, size]);
76
+ const styles = themedStyles(theme)[size];
76
77
  const shadowStyle = getShadowStyle("subtle");
77
78
  const sizeConfig = SIZE_CONFIGS[size];
78
79
  // Filled (`default`) buttons float by default; ghost/outline/link stay flat
@@ -321,6 +322,11 @@ const createStyles = (theme, size) => {
321
322
  },
322
323
  });
323
324
  };
325
+ const themedStyles = createThemedStyles((theme) => ({
326
+ sm: createStyles(theme, "sm"),
327
+ md: createStyles(theme, "md"),
328
+ lg: createStyles(theme, "lg"),
329
+ }));
324
330
  /**
325
331
  * Button with explicit subcomponents.
326
332
  * - `Button.Text` for label text (inherits control typography)
@@ -5,6 +5,7 @@ import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { useScalePress } from "../hooks/useScalePress.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
9
  /**
9
10
  * Card Component
10
11
  *
@@ -30,15 +31,15 @@ import { spacing } from "../constants/spacing.js";
30
31
  const CardContext = createContext(null);
31
32
  function useCardContext() {
32
33
  const ctx = use(CardContext);
33
- // useTheme must run unconditionally (Rules of Hooks); createCardStyles is a
34
+ // useTheme must run unconditionally (Rules of Hooks); themedStyles is a
34
35
  // plain function, so the `??` keeps the fallback styles lazy.
35
36
  const { theme } = useTheme();
36
37
  // Fallback for standalone usage without a Card parent.
37
- return ctx ?? { theme, styles: createCardStyles(theme) };
38
+ return ctx ?? { theme, styles: themedStyles(theme) };
38
39
  }
39
40
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
40
41
  const { theme, getShadowStyle } = useTheme();
41
- const styles = createCardStyles(theme);
42
+ const styles = themedStyles(theme);
42
43
  const shadowStyle = getShadowStyle("subtle");
43
44
  const ctx = { theme, styles };
44
45
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
@@ -122,4 +123,5 @@ const createCardStyles = (theme) => StyleSheet.create({
122
123
  lineHeight: 20,
123
124
  },
124
125
  });
126
+ const themedStyles = createThemedStyles(createCardStyles);
125
127
  export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
2
  import { View, StyleSheet } from "react-native";
4
3
  import { SansSerifBoldText, SansSerifText } from "./StyledText.js";
5
4
  import { Button } from "./Button.js";
6
5
  import { Icon } from "./Icon.js";
7
6
  import { useTheme } from "../hooks/useTheme.js";
8
7
  import { spacing } from "../constants/spacing.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
9
  /**
10
10
  * EmptyState Component
11
11
  *
@@ -25,7 +25,7 @@ import { spacing } from "../constants/spacing.js";
25
25
  */
26
26
  export function EmptyState({ icon, iconSize = spacing.iconMd, title, description, actionLabel, onAction, actionPreset = "default", style, children, bordered = false, }) {
27
27
  const { theme } = useTheme();
28
- const styles = useMemo(() => createStyles(theme), [theme]);
28
+ const styles = themedStyles(theme);
29
29
  return (_jsxs(View, { style: [styles.container, bordered && styles.bordered, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, size: "lg", style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, size: "base", style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
30
30
  }
31
31
  const MEDIA_SIZE = 56;
@@ -74,3 +74,4 @@ const createStyles = (theme) => StyleSheet.create({
74
74
  alignSelf: "center",
75
75
  },
76
76
  });
77
+ const themedStyles = createThemedStyles(createStyles);
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useRef, useState, useCallback } from "react";
2
+ import { useRef, useState, useCallback } from "react";
3
3
  import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { fontFamilies } from "../constants/fonts.js";
7
7
  import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
9
  const CELL_WIDTH = 36;
9
10
  const CELL_HEIGHT = 40;
10
11
  const CELL_FONT_SIZE = 20;
@@ -30,7 +31,7 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
30
31
  const { theme } = useTheme();
31
32
  const inputRef = useRef(null);
32
33
  const [focused, setFocused] = useState(false);
33
- const styles = useMemo(() => createStyles(theme), [theme]);
34
+ const styles = themedStyles(theme);
34
35
  const hasError = error || !!errorText;
35
36
  // Active cell index: next empty cell, or last cell when full
36
37
  const activeIndex = Math.min(value.length, length - 1);
@@ -132,4 +133,5 @@ const createStyles = (theme) => StyleSheet.create({
132
133
  marginTop: spacing.xs,
133
134
  },
134
135
  });
136
+ const themedStyles = createThemedStyles(createStyles);
135
137
  export { InputOTP };
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
2
  import { StyleSheet, Platform } from "react-native";
4
3
  import * as LabelPrimitive from "@rn-primitives/label";
5
4
  import { useTheme } from "../hooks/useTheme.js";
6
5
  import { spacing } from "../constants/spacing.js";
7
6
  import { fontFamilies } from "../constants/fonts.js";
8
7
  import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
9
  const SIZE_CONFIGS = {
10
10
  sm: { fontSize: 12 },
11
11
  md: { fontSize: 14 },
@@ -37,7 +37,7 @@ const SIZE_CONFIGS = {
37
37
  */
38
38
  export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
39
39
  const { theme } = useTheme();
40
- const styles = useMemo(() => createStyles(theme), [theme]);
40
+ const styles = themedStyles(theme);
41
41
  const sizeConfig = SIZE_CONFIGS[size];
42
42
  const textStyle = StyleSheet.flatten([
43
43
  styles.label,
@@ -78,3 +78,4 @@ const createStyles = (theme) => StyleSheet.create({
78
78
  opacity: 0.6,
79
79
  },
80
80
  });
81
+ const themedStyles = createThemedStyles(createStyles);
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useCallback, use, useEffect, useEffectEvent, useRef } from "react";
2
+ 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";
@@ -10,6 +10,7 @@ import { durations } from "../constants/motion.js";
10
10
  import { StyledText } from "./StyledText.js";
11
11
  import { palette } from "../constants/colors.js";
12
12
  import { translateText } from "../lib/i18n.js";
13
+ import { createThemedStyles } from "../lib/themedStyles.js";
13
14
  import { globalUIStore } from "../state/globalUIStore.js";
14
15
  const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
15
16
  const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
@@ -36,7 +37,7 @@ export const Notification = () => {
36
37
  const reduceMotion = useReducedMotion();
37
38
  const insets = use(SafeAreaInsetsContext);
38
39
  const { alert, hide } = globalUIStore();
39
- const styles = useMemo(() => createStyles(theme), [theme]);
40
+ const styles = themedStyles(theme);
40
41
  const position = alert?.position ?? "top";
41
42
  const isBottom = position === "bottom";
42
43
  // Just opacity + translateY — no scale (scale = bouncy feel)
@@ -301,3 +302,4 @@ const createStyles = (theme) => StyleSheet.create({
301
302
  ...(Platform.OS === "web" && { cursor: "pointer" }),
302
303
  },
303
304
  });
305
+ const themedStyles = createThemedStyles(createStyles);
@@ -4,6 +4,7 @@ import { View, StyleSheet, Animated } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
7
8
  /**
8
9
  * Skeleton Component
9
10
  *
@@ -72,7 +73,7 @@ export function SkeletonAvatar({ size = 40, style }) {
72
73
  */
73
74
  export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
74
75
  const { theme, getShadowStyle } = useTheme();
75
- const styles = createCardStyles(theme);
76
+ const styles = themedStyles(theme);
76
77
  return (_jsxs(View, { style: [styles.card, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
77
78
  }
78
79
  const createCardStyles = (theme) => StyleSheet.create({
@@ -97,3 +98,4 @@ const createCardStyles = (theme) => StyleSheet.create({
97
98
  gap: spacing.xs,
98
99
  },
99
100
  });
101
+ const themedStyles = createThemedStyles(createCardStyles);
@@ -2,7 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { use } from "react";
3
3
  import { Platform, Text as RNText, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
- import { fontFamilies } from "../constants/fonts.js";
5
+ import { fontFamilies, defaultWebWeightStrategy } from "../constants/fonts.js";
6
+ import { useThemeStore } from "../state/themeStore.js";
6
7
  import { translateText } from "../lib/i18n.js";
7
8
  import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
9
  const FONT_SIZES = {
@@ -89,6 +90,9 @@ const WEB_FONT_WEIGHTS = {
89
90
  export function StyledText(props) {
90
91
  const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, size, semantic, align, selectable, children, ref, ...otherProps } = props;
91
92
  const { theme } = useTheme();
93
+ // Subscribed, not read via getState(), so a `setFonts` call after mount
94
+ // re-renders text instead of leaving stale families on screen.
95
+ const fontOverrides = useThemeStore((s) => s.fontOverrides);
92
96
  // Check if there's a color override from parent context (e.g., Button)
93
97
  const contextColor = use(TextColorContext);
94
98
  const contextTextStyle = use(TextStyleContext);
@@ -100,18 +104,31 @@ export function StyledText(props) {
100
104
  const semanticConfig = semantic ? SEMANTIC_CONFIGS[semantic] : undefined;
101
105
  const finalSize = semanticConfig?.size ?? size ?? "body";
102
106
  const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
103
- // Get font family based on variant and weight
107
+ // Get font family based on variant and weight. Host apps can replace the
108
+ // package's bundled faces via `setFonts` (see constants/fonts.ts); merging
109
+ // per-group means an app overriding only `sansSerif` keeps the default serif.
104
110
  const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
105
- // Georgia (serif) only has one weight — use it regardless of requested weight
111
+ const overriddenFamilies = fontOverrides.families;
112
+ const serifFamilies = overriddenFamilies?.serif ?? fontFamilies.serif;
113
+ const sansSerifFamilies = overriddenFamilies?.sansSerif ?? fontFamilies.sansSerif;
114
+ // The serif slot has a single usable weight — use it regardless of the
115
+ // requested weight.
106
116
  const fontFamily = variant === "serif"
107
- ? fontFamilies.serif.regular
108
- : fontFamilies.sansSerif[fontFamilyWeight] ?? fontFamilies.sansSerif.regular;
109
- // Web ships one Inter family for every weight, so a numeric fontWeight picks
110
- // the right @font-face variant. Native ships a discrete static font file per
111
- // weight (see constants/fonts.ts) — the family name alone carries the
112
- // weight there, and adding a numeric fontWeight on top would faux-bold an
113
- // already-bold file, so this stays web-only.
114
- const resolvedFontWeight = Platform.OS === "web" ? WEB_FONT_WEIGHTS[finalFontWeight] : undefined;
117
+ ? serifFamilies.regular
118
+ : sansSerifFamilies[fontFamilyWeight] ?? sansSerifFamilies.regular;
119
+ // Weight is carried either by a numeric `fontWeight` (one multi-weight CSS
120
+ // family) or by the family name itself (discrete per-weight faces). Native
121
+ // is always the latter: adding a numeric weight on top of an already-bold
122
+ // file faux-bolds it. Web defaults to numeric for the package's own single
123
+ // "Inter" family, but an app loading per-weight faces through `expo-font`
124
+ // gets its own family per weight on web too, and must declare
125
+ // `webWeightStrategy: "family"` to avoid a second synthesised layer of bold.
126
+ const weightStrategy = Platform.OS === "web"
127
+ ? fontOverrides.webWeightStrategy ?? defaultWebWeightStrategy
128
+ : "family";
129
+ const resolvedFontWeight = weightStrategy === "numeric"
130
+ ? WEB_FONT_WEIGHTS[finalFontWeight]
131
+ : undefined;
115
132
  // Get fontSize and lineHeight from size variant
116
133
  const fontSize = FONT_SIZES[finalSize];
117
134
  const lineHeight = LINE_HEIGHTS[finalSize];
@@ -8,6 +8,7 @@ import { fontFamilies } from "../constants/fonts.js";
8
8
  import { StyledText } from "./StyledText.js";
9
9
  import { Icon } from "./Icon.js";
10
10
  import { hapticLight } from "../lib/haptics.js";
11
+ import { createThemedStyles } from "../lib/themedStyles.js";
11
12
  import { palette } from "../constants/colors.js";
12
13
  import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
13
14
  const NUMERIC_REGEX = /^[0-9]*$/;
@@ -91,7 +92,7 @@ export function TextInput(props) {
91
92
  */
92
93
  function WebTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
93
94
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
94
- const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
95
+ const styles = themedStyles(theme)[variant][size];
95
96
  const [focused, setFocused] = useState(false);
96
97
  const [contentHeight, setContentHeight] = useState(0);
97
98
  const [passwordVisible, setPasswordVisible] = useState(false);
@@ -197,7 +198,7 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
197
198
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
199
  leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
199
200
  const { theme, getContrastingColor } = useTheme();
200
- const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
201
+ const styles = themedStyles(theme)[variant][size];
201
202
  const [passwordVisible, setPasswordVisible] = useState(false);
202
203
  const hasError = error || !!errorText;
203
204
  const sizeConfig = SIZE_CONFIGS[size];
@@ -443,3 +444,9 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
443
444
  zIndex: 1,
444
445
  },
445
446
  });
447
+ const VARIANT_KEYS = ["outline", "filled", "underlined"];
448
+ const SIZE_KEYS = ["sm", "md", "lg"];
449
+ const themedStyles = createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
450
+ variant,
451
+ Object.fromEntries(SIZE_KEYS.map((size) => [size, createStyles(theme, variant, size)])),
452
+ ])));
@@ -9,19 +9,55 @@ interface NavigationFonts {
9
9
  bold: FontStyle;
10
10
  heavy: FontStyle;
11
11
  }
12
- export declare const fontFamilies: {
12
+ /** Weight slots every sans-serif family map must provide. */
13
+ export type FontFamilyWeight = "light" | "regular" | "medium" | "semibold" | "bold";
14
+ /**
15
+ * How a platform expresses weight for a given family map.
16
+ *
17
+ * - `"numeric"` — one multi-weight CSS family (the package's own web default:
18
+ * a single `"Inter"` family where a numeric `fontWeight` selects the
19
+ * `@font-face` variant).
20
+ * - `"family"` — each weight is its own separately-registered single-face
21
+ * family, so the family name alone carries the weight. Emitting a numeric
22
+ * `fontWeight` on top of one makes the renderer synthesise a second layer of
23
+ * bold, so `StyledText` suppresses it.
24
+ *
25
+ * Native is always `"family"` (discrete static font files). Web is `"numeric"`
26
+ * by default, but apps that load per-weight faces through `expo-font` — which
27
+ * registers each file as its own family on web too — must declare `"family"`.
28
+ */
29
+ export type FontWeightStrategy = "numeric" | "family";
30
+ export type FontFamilyMap = {
13
31
  serif: {
14
32
  regular: string;
15
33
  bold: string;
16
34
  };
17
- sansSerif: {
18
- light: string;
19
- regular: string;
20
- medium: string;
21
- semibold: string;
22
- bold: string;
23
- };
35
+ sansSerif: Record<FontFamilyWeight, string>;
24
36
  };
37
+ /**
38
+ * Font overrides a host app can inject to brand the package.
39
+ *
40
+ * Mirrors `setColors`: the package ships neutral defaults, but a consuming app
41
+ * almost always bundles its own faces. Without an injection point, package
42
+ * components render the package's fonts while app-authored siblings render the
43
+ * app's — and the only remaining lever is patching `node_modules`, which is
44
+ * keyed to an exact version and silently stops applying on the next bump.
45
+ *
46
+ * Partial by design: override `sansSerif` alone and `serif` keeps its default.
47
+ */
48
+ export type FontOverrides = {
49
+ families?: Partial<FontFamilyMap>;
50
+ /** Defaults to the package's own per-platform behaviour when omitted. */
51
+ webWeightStrategy?: FontWeightStrategy;
52
+ };
53
+ /** The package's built-in families. Override per-app via `setFonts`. */
54
+ export declare const fontFamilies: FontFamilyMap;
55
+ /**
56
+ * The package's own web weight strategy: one multi-weight "Inter" family, so
57
+ * weight comes from a numeric `fontWeight`. Native always behaves as
58
+ * `"family"` regardless of this value.
59
+ */
60
+ export declare const defaultWebWeightStrategy: FontWeightStrategy;
25
61
  /**
26
62
  * Typography scale — Tailwind-matching sizes for consistent text hierarchy.
27
63
  * Components can progressively adopt these tokens.
@@ -42,10 +42,17 @@ const sansSerifFamilies = isWebRuntime
42
42
  semibold: "sans-serif",
43
43
  bold: "sans-serif",
44
44
  };
45
+ /** The package's built-in families. Override per-app via `setFonts`. */
45
46
  export const fontFamilies = {
46
47
  serif: serifFamilies,
47
48
  sansSerif: sansSerifFamilies,
48
49
  };
50
+ /**
51
+ * The package's own web weight strategy: one multi-weight "Inter" family, so
52
+ * weight comes from a numeric `fontWeight`. Native always behaves as
53
+ * `"family"` regardless of this value.
54
+ */
55
+ export const defaultWebWeightStrategy = "numeric";
49
56
  export const typography = {
50
57
  xs: { fontSize: 12, lineHeight: 16 },
51
58
  sm: { fontSize: 14, lineHeight: 20 },
@@ -1,3 +1,4 @@
1
1
  export * from "./animations";
2
2
  export * from "./haptics";
3
3
  export * from "./i18n";
4
+ export * from "./themedStyles";
package/dist/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./animations.js";
2
2
  export * from "./haptics.js";
3
3
  export * from "./i18n.js";
4
+ export * from "./themedStyles.js";
@@ -0,0 +1,21 @@
1
+ import { type Theme } from "../constants/colors";
2
+ /**
3
+ * Registers theme-dependent styles at module scope so react-native-web
4
+ * inserts their CSS rules into the server-side sheet before the document
5
+ * <head> is serialized.
6
+ *
7
+ * Styles created lazily during render — the
8
+ * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
+ * RNW's sheet *after* expo-router has already captured the
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
+ * HTML then references class names that have no rules, which paints
12
+ * completely unstyled until hydration re-inserts them client-side (worst
13
+ * on the first request after a server cold start, and on every dev
14
+ * rebundle). See docs/ssr-hydration.md.
15
+ *
16
+ * Both base themes are computed eagerly at module load. Themes carrying
17
+ * color overrides (global brand or scoped) are computed once per theme
18
+ * object identity — `useTheme` keeps that identity stable, so the WeakMap
19
+ * behaves like the old `useMemo` without the render-time registration.
20
+ */
21
+ export declare function createThemedStyles<T extends object>(factory: (theme: Theme) => T): (theme: Theme) => T;
@@ -0,0 +1,33 @@
1
+ import { colors } from "../constants/colors.js";
2
+ /**
3
+ * Registers theme-dependent styles at module scope so react-native-web
4
+ * inserts their CSS rules into the server-side sheet before the document
5
+ * <head> is serialized.
6
+ *
7
+ * Styles created lazily during render — the
8
+ * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
+ * RNW's sheet *after* expo-router has already captured the
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
+ * HTML then references class names that have no rules, which paints
12
+ * completely unstyled until hydration re-inserts them client-side (worst
13
+ * on the first request after a server cold start, and on every dev
14
+ * rebundle). See docs/ssr-hydration.md.
15
+ *
16
+ * Both base themes are computed eagerly at module load. Themes carrying
17
+ * color overrides (global brand or scoped) are computed once per theme
18
+ * object identity — `useTheme` keeps that identity stable, so the WeakMap
19
+ * behaves like the old `useMemo` without the render-time registration.
20
+ */
21
+ export function createThemedStyles(factory) {
22
+ const cache = new WeakMap();
23
+ cache.set(colors.light, factory(colors.light));
24
+ cache.set(colors.dark, factory(colors.dark));
25
+ return (theme) => {
26
+ const cached = cache.get(theme);
27
+ if (cached)
28
+ return cached;
29
+ const created = factory(theme);
30
+ cache.set(theme, created);
31
+ return created;
32
+ };
33
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ThemeColors } from "../constants/colors";
2
+ import type { FontOverrides } from "../constants/fonts";
2
3
  export type ThemePreference = "system" | "light" | "dark";
3
4
  export type ResolvedTheme = "light" | "dark";
4
5
  /**
@@ -28,6 +29,12 @@ export type ThemeStore = {
28
29
  * behaves exactly as before this field existed (fully backward compatible).
29
30
  */
30
31
  colorOverrides: ColorOverrides;
32
+ /**
33
+ * App-injected font overrides, applied by `StyledText` on top of the package
34
+ * defaults. Empty by default — zero override means the package behaves
35
+ * exactly as before this field existed (fully backward compatible).
36
+ */
37
+ fontOverrides: FontOverrides;
31
38
  setTheme: (theme: ThemePreference) => void;
32
39
  setSystemTheme: (theme: ResolvedTheme) => void;
33
40
  /**
@@ -35,6 +42,16 @@ export type ThemeStore = {
35
42
  * clear overrides and fall back to the package defaults.
36
43
  */
37
44
  setColors: (overrides: ColorOverrides) => void;
45
+ /**
46
+ * Replace the active font overrides. Pass `{}` to clear them and fall back
47
+ * to the package's bundled faces.
48
+ *
49
+ * Call this once at startup, after the app's fonts have been registered
50
+ * (e.g. alongside `setColors` in a theme-sync component). Apps loading
51
+ * per-weight faces via `expo-font` should also pass
52
+ * `webWeightStrategy: "family"` — see `FontWeightStrategy`.
53
+ */
54
+ setFonts: (overrides: FontOverrides) => void;
38
55
  loadTheme: () => void;
39
56
  };
40
57
  export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
@@ -19,9 +19,15 @@ export const useThemeStore = create((set) => ({
19
19
  // No overrides by default: the package renders with its built-in palette
20
20
  // until a host app calls `setColors`.
21
21
  colorOverrides: {},
22
+ // Same contract as `colorOverrides`: the package renders with its bundled
23
+ // faces until a host app calls `setFonts`.
24
+ fontOverrides: {},
22
25
  setColors: (overrides) => {
23
26
  set({ colorOverrides: overrides ?? {} });
24
27
  },
28
+ setFonts: (overrides) => {
29
+ set({ fontOverrides: overrides ?? {} });
30
+ },
25
31
  setTheme: (theme) => {
26
32
  set({
27
33
  userTheme: theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [