@mrmeg/expo-ui 0.16.0 → 0.18.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,79 @@
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.18.0]
7
+
8
+ ### Added
9
+
10
+ - **`BottomSheet.Content` `backgroundStyle` pass-through.** The native sheet
11
+ surface (web vaul panel, Android `containerColor`, iOS
12
+ `presentationBackground`) previously hardcoded the themed card color.
13
+ `BottomSheet.Content` now accepts an optional `backgroundStyle` merged over
14
+ that default, so hosts can pass `{ backgroundColor: "transparent" }` to let
15
+ custom chrome (e.g. a Liquid Glass backdrop) show through. Omitting the prop
16
+ preserves the existing card background. The RN content column still paints
17
+ its own card fill; clear that via `style` when going transparent.
18
+
19
+ ## [0.17.0]
20
+
21
+ ### Added
22
+
23
+ - **`mono` font variant.** `StyledText` accepts `variant="mono"` (plus a
24
+ `MonoText` convenience alias) for code, IDs, and tabular figures. Defaults
25
+ to the platform's system monospace (Menlo on iOS, `monospace` on Android, a
26
+ `ui-monospace` stack on web); host apps swap in their own faces via the new
27
+ `mono` group in `setFonts`.
28
+
29
+ - **Per-weight-partial font override groups.** `setFonts` families are now
30
+ partial at both levels: per group (`sansSerif` alone leaves `serif`/`mono`
31
+ at their defaults, as before) and per weight — weights missing from an
32
+ overridden group fall back to that group's `regular`, never to a package
33
+ face. Registering only Regular + Medium is enough. `serif` also gained the
34
+ full weight range (previously `regular`/`bold` only, and `SerifText`
35
+ ignored the requested weight; it now resolves per weight).
36
+
37
+ - **`useResources` skips the Inter download when `sansSerif` is overridden.**
38
+ A host app that forwards its own sans faces owns loading them; nothing
39
+ would reference the packaged Inter, so the native `.ttf` fetch and the web
40
+ Google-Fonts stylesheet are skipped. Call `setFonts` before the hook mounts
41
+ for the skip to apply; the Feather icon font always loads. Without
42
+ overrides, loading is unchanged.
43
+
44
+ - **`setShape` — shape injection, completing the `setColors`/`setFonts`
45
+ trio.** Grouped per component for future growth; currently covers Button:
46
+
47
+ ```ts
48
+ useThemeStore.getState().setShape({
49
+ button: { borderRadius: 9999, withShadow: false },
50
+ });
51
+ ```
52
+
53
+ `borderRadius` applies to every Button preset (package default stays 12);
54
+ `withShadow` controls the `default` preset's resting shadow (default stays
55
+ on). Precedence is caller-wins throughout: the per-instance `withShadow`
56
+ prop and caller `style` beat the global override, which beats the package
57
+ default. `setShape({})` clears back to the defaults.
58
+
59
+ - **Every text-rendering component now resolves families through the theme
60
+ store.** Button labels, Label, Switch track labels, InputOTP cells, and
61
+ TextInput previously hardcoded Inter family constants in their static
62
+ styles, so `setFonts` re-skinned `StyledText` but left control chrome on
63
+ Inter. They now share one resolver (`resolveFontStyle`, surfaced to
64
+ components as the `useFontStyle` hook, both exported), so a `setFonts` call
65
+ re-skins the whole package at once.
66
+
67
+ - Exported `FontVariant`, `FontFamilyOverride`, `ResolvedFontStyle`,
68
+ `resolveFontStyle` (from `constants`), `useFontStyle` (from `hooks`),
69
+ `ShapeOverrides` (from `state`), and `MonoText` (from `components`).
70
+
71
+ ### Why
72
+
73
+ A consuming app rethemed to a new design language (custom sans/serif/mono
74
+ faces, pill buttons, flat surfaces) and hit the package's remaining hardcoded
75
+ Inter references, the fixed button radius, and the always-on default-preset
76
+ shadow — none of which were themable. Fully backward compatible: with no
77
+ `setFonts`/`setShape` call, fonts, radii, and shadows are identical to 0.16.0.
78
+
6
79
  ## [0.16.0]
7
80
 
8
81
  ### Added
package/LLM_USAGE.md CHANGED
@@ -76,6 +76,11 @@ On native, `BottomSheet.Content` avoids the soft keyboard by default with
76
76
  React Native keyboard events. Pass `avoidKeyboard={false}` to opt out for a
77
77
  specific sheet.
78
78
 
79
+ `BottomSheet.Content` themes the native sheet surface with the card color.
80
+ Pass `backgroundStyle={{ backgroundColor: "transparent" }}` (with a `style`
81
+ that clears the content column's card fill) when custom chrome such as a
82
+ glass backdrop should show through; omitting it keeps the card background.
83
+
79
84
  i18n is optional. Do not add app-level i18n setup just to use this package.
80
85
  Plain children and `text` props work without `i18next` or `react-i18next`.
81
86
  `tx` props render fallback text when provided and otherwise render the key
@@ -164,6 +169,15 @@ React context, scoped keys win over the global brand inside it, and nested
164
169
  scopes merge (inner wins, outer fills in). With no override at either layer,
165
170
  `useTheme()` returns the base theme by reference.
166
171
 
172
+ Fonts and shape have matching global injection points. `setFonts({ families:
173
+ { sansSerif?, serif?, mono? }, webWeightStrategy? })` replaces the bundled
174
+ faces (Inter/Georgia/system-mono) everywhere text renders; groups and weights
175
+ are partial, missing weights fall back to the group's `regular`, and an
176
+ overridden `sansSerif` makes `useResources` skip downloading Inter (call
177
+ `setFonts` before mount for the skip). `setShape({ button: { borderRadius?,
178
+ withShadow? } })` re-shapes Buttons globally (e.g. pill radius 9999, shadow
179
+ off). Per-instance props and caller `style` always win over both.
180
+
167
181
  ## Component Use-Case Index
168
182
 
169
183
  Use this table before creating a new app-local primitive.
package/README.md CHANGED
@@ -165,9 +165,10 @@ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
165
165
 
166
166
  ### Font overrides
167
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:
168
+ The package bundles Inter (sans), Georgia (serif), and the platform's system
169
+ monospace (mono). Most apps ship their own faces — forward them with
170
+ `setFonts`, the font counterpart to `setColors`. Call it once at startup,
171
+ after your fonts are registered:
171
172
 
172
173
  ```tsx
173
174
  import { useThemeStore } from "@mrmeg/expo-ui/state";
@@ -175,19 +176,40 @@ import { useThemeStore } from "@mrmeg/expo-ui/state";
175
176
  useThemeStore.getState().setFonts({
176
177
  families: {
177
178
  sansSerif: {
178
- light: "HankenGrotesk_300Light",
179
179
  regular: "HankenGrotesk_400Regular",
180
180
  medium: "HankenGrotesk_500Medium",
181
181
  semibold: "HankenGrotesk_600SemiBold",
182
182
  bold: "HankenGrotesk_700Bold",
183
183
  },
184
+ serif: {
185
+ regular: "Newsreader_400Regular",
186
+ semibold: "Newsreader_600SemiBold",
187
+ },
188
+ mono: {
189
+ regular: "JetBrainsMono_400Regular",
190
+ medium: "JetBrainsMono_500Medium",
191
+ },
184
192
  },
185
193
  webWeightStrategy: "family",
186
194
  });
187
195
  ```
188
196
 
189
- Overrides are partial and per-group: pass `sansSerif` alone and `serif` keeps
190
- the package default. `setFonts({})` clears back to the defaults.
197
+ Overrides are partial at both levels:
198
+
199
+ - **Per group** — pass `sansSerif` alone and `serif`/`mono` keep the package
200
+ defaults.
201
+ - **Per weight** — within an overridden group, weights you don't provide fall
202
+ back to that group's `regular` (never to a package face), so registering
203
+ only Regular + Medium is enough.
204
+
205
+ `setFonts({})` clears back to the defaults.
206
+
207
+ When `sansSerif` is overridden, `useResources` skips downloading the packaged
208
+ Inter faces entirely (native `.ttf`s and the web Google-Fonts stylesheet) —
209
+ your app owns loading its own families, and nothing would reference Inter.
210
+ For the skip to apply, call `setFonts` before the hook mounts (module scope,
211
+ or ahead of rendering the root); a later call still re-skins all text, it
212
+ just doesn't prevent the Inter download. The Feather icon font always loads.
191
213
 
192
214
  **Pick the right `webWeightStrategy`** — this is the one non-obvious part:
193
215
 
@@ -207,6 +229,26 @@ Native always behaves as `"family"` regardless of this setting.
207
229
  > `patchedDependencies` to an exact `name@version`, so a font patch silently
208
230
  > stops applying the next time you bump the package — with no warning.
209
231
 
232
+ ### Shape overrides
233
+
234
+ `setShape` is the geometry counterpart to `setColors`/`setFonts`, grouped per
235
+ component. It currently covers Button:
236
+
237
+ ```tsx
238
+ import { useThemeStore } from "@mrmeg/expo-ui/state";
239
+
240
+ useThemeStore.getState().setShape({
241
+ button: {
242
+ borderRadius: 9999, // pill buttons everywhere; package default is 12
243
+ withShadow: false, // flatten the `default` preset; package default is true
244
+ },
245
+ });
246
+ ```
247
+
248
+ Precedence is unchanged and caller-wins: a per-instance `style={{ borderRadius }}`
249
+ or `withShadow` prop still beats the global override, which beats the package
250
+ default. `setShape({})` clears back to the defaults.
251
+
210
252
  Use `StyledText` for theme-aware text:
211
253
 
212
254
  ```tsx
@@ -223,7 +265,8 @@ Useful `StyledText` props:
223
265
  - `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`
224
266
  - `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
225
267
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
226
- - `variant`: `sansSerif`, `serif`
268
+ - `variant`: `sansSerif`, `serif`, `mono` (or the `MonoText` alias for code,
269
+ IDs, and tabular figures)
227
270
  - `align`, `tx`, `txOptions`
228
271
  - `selectable`: defaults to `true` for readable copy; package controls disable
229
272
  selection for labels and other interactive chrome where accidental drag
@@ -331,7 +374,7 @@ Use `StyledText` or its aliases instead of raw `Text` whenever the text is part
331
374
  />
332
375
  ```
333
376
 
334
- Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
377
+ Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. `BottomSheet.Content` also accepts `backgroundStyle`, merged over the themed card default on the native sheet surface (web panel, Android `containerColor`, iOS `presentationBackground`) — pass `{ backgroundColor: "transparent" }`, plus a `style` clearing the content column's card fill, to let custom chrome like a glass backdrop show through. Trigger transient feedback with `notify`.
335
378
 
336
379
  Use `Skeleton` components for loading content with stable dimensions, `EmptyState` for no-data/recoverable errors, `Alert` for blocking confirm/alert dialogs, and `Notification` for transient global feedback.
337
380
 
@@ -117,6 +117,14 @@ interface BottomSheetContentProps extends ViewProps {
117
117
  avoidKeyboard?: boolean;
118
118
  /** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
119
119
  dismissKeyboardOnDrag?: boolean;
120
+ /**
121
+ * Style for the native sheet surface behind the RN content — the web (vaul)
122
+ * panel, Android `containerColor`, iOS `presentationBackground`. Merged over
123
+ * the themed card default, so `{ backgroundColor: "transparent" }` lets a
124
+ * custom chrome (e.g. a glass backdrop) show through. The RN content column
125
+ * paints its own card fill too; clear that via `style`.
126
+ */
127
+ backgroundStyle?: StyleProp<ViewStyle>;
120
128
  style?: StyleProp<ViewStyle>;
121
129
  children: React.ReactNode;
122
130
  }
@@ -140,7 +148,7 @@ interface BottomSheetCloseProps {
140
148
  declare function useBottomSheetContext(): BottomSheetContextValue;
141
149
  declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
142
150
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
143
- declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
151
+ declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
144
152
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
145
153
  declare function BottomSheetHeader({ children, style, ...props }: BottomSheetHeaderProps): React.JSX.Element;
146
154
  declare function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }: BottomSheetBodyProps): React.JSX.Element;
@@ -296,7 +296,7 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
296
296
  // ============================================================================
297
297
  function BottomSheetContent({
298
298
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
299
- swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }) {
299
+ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }) {
300
300
  const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
301
301
  const { theme } = useTheme();
302
302
  const dismissDisabled = useDismissDisabled();
@@ -337,8 +337,13 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
337
337
  if (open)
338
338
  onOpenChange(false);
339
339
  },
340
- // Themes the scrim/background on web (vaul) and Android (containerColor).
341
- backgroundStyle: { backgroundColor: theme.colors.card }, children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
340
+ // Themes the scrim/background on web (vaul), Android (containerColor),
341
+ // and iOS (presentationBackground). Flattened so native readers that
342
+ // expect a plain object (not a style array) keep working.
343
+ backgroundStyle: StyleSheet.flatten([
344
+ { backgroundColor: theme.colors.card },
345
+ backgroundStyleOverride,
346
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
342
347
  {
343
348
  flex: 1,
344
349
  maxHeight: detentHeight,
@@ -1,14 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { use, useCallback, useState } from "react";
2
+ import { use, useCallback, useMemo, 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";
6
6
  import { Icon } from "./Icon.js";
7
7
  import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
- import { fontFamilies } from "../constants/fonts.js";
9
8
  import { palette } from "../constants/colors.js";
10
9
  import { useTheme } from "../hooks/useTheme.js";
10
+ import { useFontStyle } from "../hooks/useFontStyle.js";
11
11
  import { useScalePress } from "../hooks/useScalePress.js";
12
+ import { useThemeStore } from "../state/themeStore.js";
12
13
  import { createThemedStyles } from "../lib/themedStyles.js";
13
14
  const SIZE_CONFIGS = {
14
15
  sm: {
@@ -76,11 +77,21 @@ function ButtonRoot(props) {
76
77
  const styles = themedStyles(theme)[size];
77
78
  const shadowStyle = getShadowStyle("subtle");
78
79
  const sizeConfig = SIZE_CONFIGS[size];
80
+ // Host-app shape overrides (radius, default-preset shadow) — see `setShape`.
81
+ const buttonShape = useThemeStore((s) => s.shapeOverrides.button);
79
82
  // Filled (`default`) buttons float by default; ghost/outline/link stay flat
80
83
  // (transparent background reads oddly with a shadow), and destructive/secondary
81
- // keep the old opt-in behavior. Callers can still override via `withShadow`.
82
- const withShadow = withShadowProp ?? preset === "default";
84
+ // keep the old opt-in behavior. Precedence: the per-instance `withShadow`
85
+ // prop, then the host app's `setShape` flag, then the preset default.
86
+ const withShadow = withShadowProp
87
+ ?? (preset === "default" ? buttonShape?.withShadow ?? true : false);
83
88
  const focusRingStyle = getFocusRingStyle();
89
+ // Label typography resolves through the theme store so `setFonts` re-skins
90
+ // button labels along with everything else. Provided via TextStyleContext
91
+ // (after the static size styles) so nested Button.Text / StyledText children
92
+ // inherit it too, exactly like the previous hardcoded family did.
93
+ const labelFontStyle = useFontStyle("medium");
94
+ const labelTextStyle = useMemo(() => [styles.text, { fontFamily: labelFontStyle.fontFamily, fontWeight: labelFontStyle.fontWeight }], [styles.text, labelFontStyle.fontFamily, labelFontStyle.fontWeight]);
84
95
  // Pre-compute background color for contrast calculation
85
96
  // Always flatten to handle both array styles (from Slot) and RegisteredStyle
86
97
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
@@ -170,8 +181,12 @@ function ButtonRoot(props) {
170
181
  return nextWidth;
171
182
  });
172
183
  }, [fullWidth, loading]);
173
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: styles.text, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
184
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
174
185
  styles.button,
186
+ // Host-app radius override (see `setShape`). Sits right
187
+ // after the static radius so presets, state styles, and —
188
+ // critically — the caller `style` spread below still win.
189
+ buttonShape?.borderRadius !== undefined && { borderRadius: buttonShape.borderRadius },
175
190
  preset === "default" && styles.buttonDefault,
176
191
  preset === "outline" && styles.buttonOutline,
177
192
  preset === "ghost" && styles.buttonGhost,
@@ -191,7 +206,7 @@ function ButtonRoot(props) {
191
206
  // Spread array styles from Slot to prevent nested arrays on web
192
207
  ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
193
208
  ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
194
- styles.text,
209
+ labelTextStyle,
195
210
  state.pressed && styles.pressedText,
196
211
  state.pressed && pressedTextStyleOverride,
197
212
  isDisabled && disabledTextStyleOverride,
@@ -283,15 +298,11 @@ const createStyles = (theme, size) => {
283
298
  width: "100%",
284
299
  },
285
300
  text: {
286
- // Button labels render at medium weight. On native, the family itself
287
- // carries the weight (a real static Inter_500Medium file) — pairing it
288
- // with a numeric fontWeight would faux-bold on top of that file. Web
289
- // shares one "Inter" family across weights, so it needs the numeric
290
- // fontWeight to pick the right @font-face variant. (Same rule as
291
- // StyledText's getFontFamilyWeight / WEB_FONT_WEIGHTS.)
292
- fontFamily: fontFamilies.sansSerif.medium,
301
+ // Button labels render at medium weight. The fontFamily (+ numeric
302
+ // fontWeight on web) is NOT set here: it's resolved per-render through
303
+ // the theme store (`useFontStyle("medium")` in ButtonRoot) and layered
304
+ // right after this style, so `setFonts` overrides reach button labels.
293
305
  fontSize: sizeConfig.fontSize,
294
- ...(Platform.OS === "web" && { fontWeight: "500" }),
295
306
  textAlign: "center",
296
307
  lineHeight: sizeConfig.fontSize * 1.4,
297
308
  flexShrink: 0,
@@ -3,13 +3,11 @@ 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
- import { fontFamilies } from "../constants/fonts.js";
7
6
  import { StyledText } from "./StyledText.js";
8
7
  import { createThemedStyles } from "../lib/themedStyles.js";
9
8
  const CELL_WIDTH = 36;
10
9
  const CELL_HEIGHT = 40;
11
10
  const CELL_FONT_SIZE = 20;
12
- const CELL_FONT_WEIGHT = "600";
13
11
  const BULLET = "\u2022";
14
12
  /**
15
13
  * OTP/verification code input with individual character cells.
@@ -98,14 +96,8 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
98
96
  opacity: disabled ? 0.5 : 1,
99
97
  borderColor,
100
98
  },
101
- ], children: _jsx(StyledText, { selectable: false, style: {
99
+ ], children: _jsx(StyledText, { selectable: false, fontWeight: "semibold", style: {
102
100
  fontSize: CELL_FONT_SIZE,
103
- // Semibold weight: the family carries it on native (a real static
104
- // Inter_600SemiBold file); web shares one "Inter" family across
105
- // weights, so it needs the numeric fontWeight to pick the right
106
- // @font-face variant (same rule as StyledText's WEB_FONT_WEIGHTS).
107
- ...(Platform.OS === "web" && { fontWeight: CELL_FONT_WEIGHT }),
108
- fontFamily: fontFamilies.sansSerif.semibold,
109
101
  color: theme.colors.text,
110
102
  textAlign: "center",
111
103
  lineHeight: CELL_FONT_SIZE * 1.2,
@@ -127,8 +119,9 @@ const createStyles = (theme) => StyleSheet.create({
127
119
  gap: spacing.sm,
128
120
  },
129
121
  errorText: {
122
+ // Regular weight — StyledText's default resolves the family through the
123
+ // theme store, so no hardcoded fontFamily here.
130
124
  fontSize: 12,
131
- fontFamily: fontFamilies.sansSerif.regular,
132
125
  color: theme.colors.destructive,
133
126
  marginTop: spacing.xs,
134
127
  },
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StyleSheet, Platform } from "react-native";
3
3
  import * as LabelPrimitive from "@rn-primitives/label";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
+ import { useFontStyle } from "../hooks/useFontStyle.js";
5
6
  import { spacing } from "../constants/spacing.js";
6
- import { fontFamilies } from "../constants/fonts.js";
7
7
  import { StyledText } from "./StyledText.js";
8
8
  import { createThemedStyles } from "../lib/themedStyles.js";
9
9
  const SIZE_CONFIGS = {
@@ -39,8 +39,12 @@ export function Label({ children, nativeID, required, size = "md", error, disabl
39
39
  const { theme } = useTheme();
40
40
  const styles = themedStyles(theme);
41
41
  const sizeConfig = SIZE_CONFIGS[size];
42
+ // Resolved through the theme store so `setFonts` overrides apply; identical
43
+ // to the old hardcoded medium family when no override is set.
44
+ const labelFont = useFontStyle("medium");
42
45
  const textStyle = StyleSheet.flatten([
43
46
  styles.label,
47
+ labelFont,
44
48
  { fontSize: sizeConfig.fontSize },
45
49
  error && styles.errorLabel,
46
50
  disabled && styles.disabledLabel,
@@ -49,26 +53,23 @@ export function Label({ children, nativeID, required, size = "md", error, disabl
49
53
  return (_jsx(LabelPrimitive.Root, { nativeID: nativeID, onPress: onPress, style: {
50
54
  ...styles.root,
51
55
  ...(Platform.OS === "web" && onPress && { cursor: "pointer" }),
52
- }, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) }));
56
+ }, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
53
57
  }
54
58
  const createStyles = (theme) => StyleSheet.create({
55
59
  root: {
56
60
  marginBottom: spacing.xs,
57
61
  },
58
62
  label: {
59
- // Medium weight: the family carries it on native (a real static
60
- // Inter_500Medium file); web shares one "Inter" family across weights,
61
- // so it needs the numeric fontWeight to pick the right @font-face
62
- // variant. Setting fontWeight on native would faux-bold on top of the
63
- // already-medium file (same rule as StyledText's WEB_FONT_WEIGHTS).
64
- fontFamily: fontFamilies.sansSerif.medium,
65
- ...(Platform.OS === "web" && { fontWeight: "500" }),
63
+ // Medium weight — the fontFamily (+ numeric fontWeight on web) is
64
+ // resolved per-render through the theme store (`useFontStyle("medium")`
65
+ // in Label) so `setFonts` overrides apply here too.
66
66
  color: theme.colors.text,
67
67
  userSelect: "none",
68
68
  },
69
69
  required: {
70
+ // Bold weight comes from the StyledText `fontWeight="bold"` prop, which
71
+ // resolves the family through the theme store.
70
72
  color: theme.colors.destructive,
71
- fontFamily: fontFamilies.sansSerif.bold,
72
73
  userSelect: "none",
73
74
  },
74
75
  errorLabel: {
@@ -1,5 +1,6 @@
1
1
  import { type Ref } from "react";
2
2
  import { Text as RNText, TextProps as RNTextProps } from "react-native";
3
+ import { type FontVariant } from "../constants/fonts";
3
4
  /**
4
5
  * Font size variants for the type scale (Inter sans-serif / Georgia serif)
5
6
  */
@@ -18,9 +19,10 @@ export type FontWeight = "light" | "regular" | "medium" | "semibold" | "bold";
18
19
  export type TextAlign = "left" | "center" | "right" | "justify" | "auto";
19
20
  export type TextProps = RNTextProps & {
20
21
  /**
21
- * Font variant - serif (Georgia) or sans-serif (Inter)
22
+ * Font variant - serif (Georgia), sans-serif (Inter), or mono (the
23
+ * platform's system monospace). Host apps swap the families via `setFonts`.
22
24
  */
23
- variant?: "serif" | "sansSerif";
25
+ variant?: FontVariant;
24
26
  /**
25
27
  * Font weight - light, regular, medium, semibold, bold
26
28
  */
@@ -83,6 +85,13 @@ export declare function SerifText(props: TextProps): import("react").JSX.Element
83
85
  * Uses the sans-serif font family (Inter)
84
86
  */
85
87
  export declare function SansSerifText(props: TextProps): import("react").JSX.Element;
88
+ /**
89
+ * Mono Text Component
90
+ * Uses the monospace font family (the platform's system monospace by
91
+ * default; host apps swap in their own via `setFonts`) — for code, IDs,
92
+ * and tabular figures
93
+ */
94
+ export declare function MonoText(props: TextProps): import("react").JSX.Element;
86
95
  /**
87
96
  * Serif Bold Text Component
88
97
  * Uses the serif font family — Georgia's only shipped weight is regular
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { use } from "react";
3
- import { Platform, Text as RNText, StyleSheet } from "react-native";
3
+ import { Text as RNText, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
- import { fontFamilies, defaultWebWeightStrategy } from "../constants/fonts.js";
5
+ import { resolveFontStyle } from "../constants/fonts.js";
6
6
  import { useThemeStore } from "../state/themeStore.js";
7
7
  import { translateText } from "../lib/i18n.js";
8
8
  import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
@@ -55,22 +55,6 @@ const SEMANTIC_CONFIGS = {
55
55
  // and the textTransform applied in StyledText below.
56
56
  eyebrow: { size: "sm", weight: "semibold" },
57
57
  };
58
- // Font family weight keys now map 1:1 onto FontWeight — every weight has a
59
- // real family entry (native: a distinct static Inter file; web: shared
60
- // "Inter" family + numeric fontWeight, see WEB_FONT_WEIGHTS). Defaults to
61
- // "regular" when no weight is requested.
62
- const getFontFamilyWeight = (weight) => weight ?? "regular";
63
- // Web-only: the numeric fontWeight that picks the right @font-face variant
64
- // out of the single shared "Inter" CSS family. "light" has no loaded 300
65
- // weight (mirrors the native light->regular mapping in constants/fonts.ts),
66
- // so it renders at 400 like "regular".
67
- const WEB_FONT_WEIGHTS = {
68
- light: "400",
69
- regular: "400",
70
- medium: "500",
71
- semibold: "600",
72
- bold: "700",
73
- };
74
58
  /**
75
59
  * Base Text component with theme and variant support
76
60
  * Uses theme colors automatically - text by default
@@ -104,31 +88,12 @@ export function StyledText(props) {
104
88
  const semanticConfig = semantic ? SEMANTIC_CONFIGS[semantic] : undefined;
105
89
  const finalSize = semanticConfig?.size ?? size ?? "body";
106
90
  const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
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.
110
- const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
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.
116
- const fontFamily = variant === "serif"
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;
91
+ // Get font family (+ numeric fontWeight under the web "numeric" strategy)
92
+ // based on variant and weight. Host apps replace the package's bundled
93
+ // faces via `setFonts`; resolution — including the per-group merge and the
94
+ // numeric-vs-family weight strategy — lives in `resolveFontStyle`
95
+ // (constants/fonts.ts), shared with the control components' `useFontStyle`.
96
+ const { fontFamily, fontWeight: resolvedFontWeight } = resolveFontStyle(fontOverrides, variant, finalFontWeight);
132
97
  // Get fontSize and lineHeight from size variant
133
98
  const fontSize = FONT_SIZES[finalSize];
134
99
  const lineHeight = LINE_HEIGHTS[finalSize];
@@ -177,6 +142,15 @@ export function SerifText(props) {
177
142
  export function SansSerifText(props) {
178
143
  return _jsx(StyledText, { ...props, variant: "sansSerif" });
179
144
  }
145
+ /**
146
+ * Mono Text Component
147
+ * Uses the monospace font family (the platform's system monospace by
148
+ * default; host apps swap in their own via `setFonts`) — for code, IDs,
149
+ * and tabular figures
150
+ */
151
+ export function MonoText(props) {
152
+ return _jsx(StyledText, { ...props, variant: "mono" });
153
+ }
180
154
  /**
181
155
  * Serif Bold Text Component
182
156
  * Uses the serif font family — Georgia's only shipped weight is regular
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
- import { fontFamilies } from "../constants/fonts.js";
4
3
  import { spacing } from "../constants/spacing.js";
5
4
  import { useTheme } from "../hooks/useTheme.js";
6
5
  import { hapticLight } from "../lib/haptics.js";
@@ -119,8 +118,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
119
118
  left: labelHorizontalInset,
120
119
  right: thumbInset + thumbSize + labelGap,
121
120
  opacity: props.checked ? 1 : 0,
122
- }, children: _jsx(StyledText, { selectable: false, style: {
123
- fontFamily: fontFamilies.sansSerif.bold,
121
+ }, children: _jsx(StyledText, { selectable: false, fontWeight: "bold", style: {
124
122
  fontSize: labelFontSize,
125
123
  color: labelOnColor,
126
124
  userSelect: "none",
@@ -143,8 +141,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
143
141
  left: thumbInset + thumbSize + labelGap,
144
142
  right: labelHorizontalInset,
145
143
  opacity: props.checked ? 0 : 1,
146
- }, children: _jsx(StyledText, { selectable: false, style: {
147
- fontFamily: fontFamilies.sansSerif.bold,
144
+ }, children: _jsx(StyledText, { selectable: false, fontWeight: "bold", style: {
148
145
  fontSize: labelFontSize,
149
146
  color: theme.colors.text,
150
147
  userSelect: "none",
@@ -4,7 +4,7 @@ import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from
4
4
  import { Host, TextInput as ExpoTextInput, useNativeState, } from "@expo/ui";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
- import { fontFamilies } from "../constants/fonts.js";
7
+ import { useFontStyle } from "../hooks/useFontStyle.js";
8
8
  import { StyledText } from "./StyledText.js";
9
9
  import { Icon } from "./Icon.js";
10
10
  import { hapticLight } from "../lib/haptics.js";
@@ -93,6 +93,7 @@ export function TextInput(props) {
93
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 }) {
94
94
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
95
95
  const styles = themedStyles(theme)[variant][size];
96
+ const inputFont = useFontStyle("regular");
96
97
  const [focused, setFocused] = useState(false);
97
98
  const [contentHeight, setContentHeight] = useState(0);
98
99
  const [passwordVisible, setPasswordVisible] = useState(false);
@@ -149,6 +150,9 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
149
150
  };
150
151
  return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [styles.wrapper, focused && getFocusRingStyle()], children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: showInputFocusRing, onBlur: hideInputFocusRing, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
151
152
  styles.input,
153
+ // Resolved through the theme store so `setFonts` overrides apply;
154
+ // identical to the old hardcoded regular family by default.
155
+ inputFont,
152
156
  {
153
157
  backgroundColor,
154
158
  borderColor,
@@ -199,6 +203,7 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
199
203
  leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
200
204
  const { theme, getContrastingColor } = useTheme();
201
205
  const styles = themedStyles(theme)[variant][size];
206
+ const inputFont = useFontStyle("regular");
202
207
  const [passwordVisible, setPasswordVisible] = useState(false);
203
208
  const hasError = error || !!errorText;
204
209
  const sizeConfig = SIZE_CONFIGS[size];
@@ -319,10 +324,12 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
319
324
  // @expo/ui passes the family verbatim to SwiftUI's Font.custom / Compose,
320
325
  // where no such font exists — the fallback ignores fontSize and renders at
321
326
  // the 17pt default, blowing up text and secure-entry dots. Omit the family
322
- // so the native side uses the system font at our requested size.
323
- const nativeFontFamily = fontFamilies.sansSerif.regular === "System"
327
+ // so the native side uses the system font at our requested size. The family
328
+ // itself resolves through the theme store (`useFontStyle` above) so
329
+ // `setFonts` overrides reach the native field too.
330
+ const nativeFontFamily = inputFont.fontFamily === "System"
324
331
  ? undefined
325
- : fontFamilies.sansSerif.regular;
332
+ : inputFont.fontFamily;
326
333
  const textStyle = {
327
334
  color: textColor,
328
335
  fontSize: sizeConfig.fontSize,
@@ -353,7 +360,8 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
353
360
  justifyContent: "center",
354
361
  },
355
362
  input: {
356
- fontFamily: fontFamilies.sansSerif.regular,
363
+ // fontFamily is resolved per-render through the theme store (see
364
+ // `useFontStyle("regular")` in WebTextInput) so `setFonts` applies.
357
365
  borderRadius: spacing.radiusMd,
358
366
  borderWidth: 1,
359
367
  ...(Platform.OS === "web" && { outlineStyle: "none" }),
@@ -27,13 +27,15 @@ export type FontFamilyWeight = "light" | "regular" | "medium" | "semibold" | "bo
27
27
  * registers each file as its own family on web too — must declare `"family"`.
28
28
  */
29
29
  export type FontWeightStrategy = "numeric" | "family";
30
- export type FontFamilyMap = {
31
- serif: {
32
- regular: string;
33
- bold: string;
34
- };
35
- sansSerif: Record<FontFamilyWeight, string>;
36
- };
30
+ /** The font variants text components can render. */
31
+ export type FontVariant = "sansSerif" | "serif" | "mono";
32
+ export type FontFamilyMap = Record<FontVariant, Record<FontFamilyWeight, string>>;
33
+ /**
34
+ * A per-variant override group. Partial per weight: missing weights fall back
35
+ * to the group's own `regular` (not the package default), so an app that
36
+ * registers only Regular + Medium never leaks a package face into its brand.
37
+ */
38
+ export type FontFamilyOverride = Partial<Record<FontFamilyWeight, string>>;
37
39
  /**
38
40
  * Font overrides a host app can inject to brand the package.
39
41
  *
@@ -43,10 +45,11 @@ export type FontFamilyMap = {
43
45
  * app's — and the only remaining lever is patching `node_modules`, which is
44
46
  * keyed to an exact version and silently stops applying on the next bump.
45
47
  *
46
- * Partial by design: override `sansSerif` alone and `serif` keeps its default.
48
+ * Partial by design: override `sansSerif` alone and `serif`/`mono` keep their
49
+ * defaults.
47
50
  */
48
51
  export type FontOverrides = {
49
- families?: Partial<FontFamilyMap>;
52
+ families?: Partial<Record<FontVariant, FontFamilyOverride>>;
50
53
  /** Defaults to the package's own per-platform behaviour when omitted. */
51
54
  webWeightStrategy?: FontWeightStrategy;
52
55
  };
@@ -58,6 +61,28 @@ export declare const fontFamilies: FontFamilyMap;
58
61
  * `"family"` regardless of this value.
59
62
  */
60
63
  export declare const defaultWebWeightStrategy: FontWeightStrategy;
64
+ export type ResolvedFontStyle = {
65
+ fontFamily: string;
66
+ /** Present only under the `"numeric"` weight strategy (web). */
67
+ fontWeight?: "400" | "500" | "600" | "700";
68
+ };
69
+ /**
70
+ * Resolve the `fontFamily` (+ optional numeric `fontWeight`) for a variant and
71
+ * weight, honouring any host-app `setFonts` overrides.
72
+ *
73
+ * This is the single source of truth every text-rendering component goes
74
+ * through — `StyledText` directly, control components via `useFontStyle` —
75
+ * so a host override re-skins package text everywhere at once.
76
+ *
77
+ * Weight is carried either by a numeric `fontWeight` (one multi-weight CSS
78
+ * family) or by the family name itself (discrete per-weight faces). Native is
79
+ * always the latter: adding a numeric weight on top of an already-bold file
80
+ * faux-bolds it. Web defaults to numeric for the package's own single "Inter"
81
+ * family, but an app loading per-weight faces through `expo-font` gets its
82
+ * own family per weight on web too, and must declare
83
+ * `webWeightStrategy: "family"` to avoid a second synthesised layer of bold.
84
+ */
85
+ export declare function resolveFontStyle(overrides: FontOverrides, variant: FontVariant, weight: FontFamilyWeight): ResolvedFontStyle;
61
86
  /**
62
87
  * Typography scale — Tailwind-matching sizes for consistent text hierarchy.
63
88
  * Components can progressively adopt these tokens.
@@ -8,9 +8,45 @@ const WEB_FONT_STACK = "system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-
8
8
  // both environments, so the value is stable.
9
9
  const isWebRuntime = Platform.OS === "web";
10
10
  const isReactNativeRuntime = Platform.OS !== "web";
11
+ // Georgia ships as one face per platform; every weight slot points at the
12
+ // same family and weight differentiation only appears when a host app
13
+ // overrides serif with real per-weight faces via `setFonts`.
14
+ const webSerifFamily = "Georgia, 'Times New Roman', serif";
11
15
  const serifFamilies = isWebRuntime
12
- ? { regular: "Georgia, 'Times New Roman', serif", bold: "Georgia, 'Times New Roman', serif" }
13
- : { regular: "Georgia", bold: "Georgia" };
16
+ ? {
17
+ light: webSerifFamily,
18
+ regular: webSerifFamily,
19
+ medium: webSerifFamily,
20
+ semibold: webSerifFamily,
21
+ bold: webSerifFamily,
22
+ }
23
+ : {
24
+ light: "Georgia",
25
+ regular: "Georgia",
26
+ medium: "Georgia",
27
+ semibold: "Georgia",
28
+ bold: "Georgia",
29
+ };
30
+ // Same single-face story as serif: the package doesn't bundle a mono font, it
31
+ // leans on each platform's system monospace. Menlo on iOS ("monospace" isn't
32
+ // a resolvable family name there); Android resolves "monospace" natively.
33
+ const WEB_MONO_STACK = "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace";
34
+ const nativeMonoFamily = Platform.OS === "ios" ? "Menlo" : "monospace";
35
+ const monoFamilies = isWebRuntime
36
+ ? {
37
+ light: WEB_MONO_STACK,
38
+ regular: WEB_MONO_STACK,
39
+ medium: WEB_MONO_STACK,
40
+ semibold: WEB_MONO_STACK,
41
+ bold: WEB_MONO_STACK,
42
+ }
43
+ : {
44
+ light: nativeMonoFamily,
45
+ regular: nativeMonoFamily,
46
+ medium: nativeMonoFamily,
47
+ semibold: nativeMonoFamily,
48
+ bold: nativeMonoFamily,
49
+ };
14
50
  // Web ships one Inter CSS family for every weight — weight differentiation
15
51
  // comes from a numeric `fontWeight` set alongside this family (see
16
52
  // StyledText's getFontFamilyWeight). Native ships four discrete Inter static
@@ -46,6 +82,7 @@ const sansSerifFamilies = isWebRuntime
46
82
  export const fontFamilies = {
47
83
  serif: serifFamilies,
48
84
  sansSerif: sansSerifFamilies,
85
+ mono: monoFamilies,
49
86
  };
50
87
  /**
51
88
  * The package's own web weight strategy: one multi-weight "Inter" family, so
@@ -53,6 +90,48 @@ export const fontFamilies = {
53
90
  * `"family"` regardless of this value.
54
91
  */
55
92
  export const defaultWebWeightStrategy = "numeric";
93
+ /**
94
+ * Web-only numeric weights, used under the `"numeric"` strategy to pick the
95
+ * right `@font-face` variant out of a single multi-weight CSS family.
96
+ * `light` has no loaded 300 weight (mirrors the native light->regular file
97
+ * mapping above), so it renders at 400 like `regular`.
98
+ */
99
+ const WEB_FONT_WEIGHTS = {
100
+ light: "400",
101
+ regular: "400",
102
+ medium: "500",
103
+ semibold: "600",
104
+ bold: "700",
105
+ };
106
+ /**
107
+ * Resolve the `fontFamily` (+ optional numeric `fontWeight`) for a variant and
108
+ * weight, honouring any host-app `setFonts` overrides.
109
+ *
110
+ * This is the single source of truth every text-rendering component goes
111
+ * through — `StyledText` directly, control components via `useFontStyle` —
112
+ * so a host override re-skins package text everywhere at once.
113
+ *
114
+ * Weight is carried either by a numeric `fontWeight` (one multi-weight CSS
115
+ * family) or by the family name itself (discrete per-weight faces). Native is
116
+ * always the latter: adding a numeric weight on top of an already-bold file
117
+ * faux-bolds it. Web defaults to numeric for the package's own single "Inter"
118
+ * family, but an app loading per-weight faces through `expo-font` gets its
119
+ * own family per weight on web too, and must declare
120
+ * `webWeightStrategy: "family"` to avoid a second synthesised layer of bold.
121
+ */
122
+ export function resolveFontStyle(overrides, variant, weight) {
123
+ const defaults = fontFamilies[variant];
124
+ const overrideGroup = overrides.families?.[variant];
125
+ const fontFamily = overrideGroup
126
+ ? overrideGroup[weight] ?? overrideGroup.regular ?? defaults[weight]
127
+ : defaults[weight];
128
+ const strategy = Platform.OS === "web"
129
+ ? overrides.webWeightStrategy ?? defaultWebWeightStrategy
130
+ : "family";
131
+ return strategy === "numeric"
132
+ ? { fontFamily, fontWeight: WEB_FONT_WEIGHTS[weight] }
133
+ : { fontFamily };
134
+ }
56
135
  export const typography = {
57
136
  xs: { fontSize: 12, lineHeight: 16 },
58
137
  sm: { fontSize: 14, lineHeight: 20 },
@@ -1,4 +1,5 @@
1
1
  export * from "./useDimensions";
2
+ export * from "./useFontStyle";
2
3
  export * from "./useReduceMotion";
3
4
  export * from "./useResources";
4
5
  export * from "./useScalePress";
@@ -1,4 +1,5 @@
1
1
  export * from "./useDimensions.js";
2
+ export * from "./useFontStyle.js";
2
3
  export * from "./useReduceMotion.js";
3
4
  export * from "./useResources.js";
4
5
  export * from "./useScalePress.js";
@@ -0,0 +1,11 @@
1
+ import { type FontFamilyWeight, type FontVariant, type ResolvedFontStyle } from "../constants/fonts";
2
+ /**
3
+ * Resolve a `{ fontFamily, fontWeight? }` style through the theme store's
4
+ * font overrides, re-rendering when a host app calls `setFonts`.
5
+ *
6
+ * For control components (Button, Label, TextInput, …) whose typography is
7
+ * a fixed weight rather than caller-driven. With no overrides set, the result
8
+ * is identical to the package's hardcoded families, so layering it after a
9
+ * static themed style changes nothing by default.
10
+ */
11
+ export declare function useFontStyle(weight?: FontFamilyWeight, variant?: FontVariant): ResolvedFontStyle;
@@ -0,0 +1,17 @@
1
+ import { resolveFontStyle, } from "../constants/fonts.js";
2
+ import { useThemeStore } from "../state/themeStore.js";
3
+ /**
4
+ * Resolve a `{ fontFamily, fontWeight? }` style through the theme store's
5
+ * font overrides, re-rendering when a host app calls `setFonts`.
6
+ *
7
+ * For control components (Button, Label, TextInput, …) whose typography is
8
+ * a fixed weight rather than caller-driven. With no overrides set, the result
9
+ * is identical to the package's hardcoded families, so layering it after a
10
+ * static themed style changes nothing by default.
11
+ */
12
+ export function useFontStyle(weight = "regular", variant = "sansSerif") {
13
+ // Subscribed, not read via getState(), so a `setFonts` call after mount
14
+ // re-renders instead of leaving stale families on screen.
15
+ const fontOverrides = useThemeStore((s) => s.fontOverrides);
16
+ return resolveFontStyle(fontOverrides, variant, weight);
17
+ }
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Registers the Feather icon font in expo-font's per-request SSR store.
3
+ *
4
+ * Must be called from a **render body** on server-web (see `useResources`
5
+ * below): expo-font enters an `AsyncLocalStorage` store per request, so a
6
+ * module-scope registration lands in at most one request's store. Registering
7
+ * during render means every request emits the `@font-face` rule into
8
+ * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
9
+ * the client's first render.
10
+ *
11
+ * No-op on native and on client web (both already covered: native by the
12
+ * effect below, client web by the module-scope call above).
13
+ */
14
+ export declare function ensureIconFontRegistered(): void;
1
15
  interface LoadResourcesResult {
2
16
  loaded: boolean;
3
17
  error: Error | null;
@@ -9,6 +23,13 @@ interface LoadResourcesResult {
9
23
  * @expo-google-fonts/inter) so StyledText's weight range resolves to real
10
24
  * font files. Web loads Inter from Google Fonts as a single CSS family;
11
25
  * weight differentiation there comes from a numeric fontWeight instead.
26
+ *
27
+ * A host app that overrides the sans-serif families via `setFonts` owns
28
+ * loading its own faces (typically through `expo-font`), so the Inter fetch
29
+ * is skipped entirely — nothing would reference those files. For the skip to
30
+ * apply, call `setFonts` before this hook mounts (module scope or ahead of
31
+ * rendering the root); a later call still re-skins text, it just doesn't
32
+ * un-download Inter. The Feather icon font always loads.
12
33
  */
13
34
  export declare const useResources: () => LoadResourcesResult;
14
35
  export {};
@@ -3,15 +3,46 @@ import * as Font from "expo-font";
3
3
  import Feather from "@expo/vector-icons/Feather";
4
4
  import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
5
5
  import { Platform } from "react-native";
6
- // Eager, module-scope load so expo-font registers Feather in its SSR
7
- // serverContext. When the server renders the HTML document, expo-font emits
8
- // an @font-face <style> into the head — without this kickoff, server-rendered
9
- // <Icon> components paint as empty glyphs until hydration runs the effect
10
- // below, producing a visible icon-pop flash on icon-heavy SSR screens
11
- // (e.g. OnboardingFlow). On the client this also primes the font ahead of
12
- // the effect; the effect's loadAsync becomes a no-op for the already-loaded
13
- // font.
14
- void Font.loadAsync(Feather.font);
6
+ import { useThemeStore } from "../state/themeStore.js";
7
+ // Eager, module-scope load that primes the Feather font on the *client* before
8
+ // first render: on web it synchronously injects the @font-face rule, which is
9
+ // what makes the client's `Font.isLoaded("feather")` check read true during
10
+ // hydration; on native it just starts the fetch ahead of the effect below
11
+ // (which then no-ops for the already-loaded font).
12
+ //
13
+ // Deliberately guarded off server-web. expo-font's SSR store is per-request
14
+ // AsyncLocalStorage, and Metro's inlineRequires defers this module's evaluation
15
+ // until it is first required during a render — so on the server this line only
16
+ // ever populated the store of whichever request happened to warm the module
17
+ // cache. Requests 2+ then shipped HTML with no @font-face and empty icons while
18
+ // the client rendered glyphs (React #418). Server registration happens
19
+ // per-request in ensureIconFontRegistered() instead.
20
+ if (typeof window !== "undefined") {
21
+ void Font.loadAsync(Feather.font);
22
+ }
23
+ /**
24
+ * Registers the Feather icon font in expo-font's per-request SSR store.
25
+ *
26
+ * Must be called from a **render body** on server-web (see `useResources`
27
+ * below): expo-font enters an `AsyncLocalStorage` store per request, so a
28
+ * module-scope registration lands in at most one request's store. Registering
29
+ * during render means every request emits the `@font-face` rule into
30
+ * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
31
+ * the client's first render.
32
+ *
33
+ * No-op on native and on client web (both already covered: native by the
34
+ * effect below, client web by the module-scope call above).
35
+ */
36
+ export function ensureIconFontRegistered() {
37
+ if (Platform.OS !== "web" || typeof window !== "undefined")
38
+ return;
39
+ if (Font.isLoaded("feather"))
40
+ return; // per-request store; cheap
41
+ // Synchronous on server-web (expo-font routes to registerStaticFont). No
42
+ // `void`/try-catch: a throw here means we're outside a request scope, which
43
+ // must be loud.
44
+ Font.loadAsync(Feather.font);
45
+ }
15
46
  // The four static Inter weights StyledText's native family keys point at
16
47
  // (see constants/fonts.ts). Native-only: web never renders these family names
17
48
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -56,18 +87,32 @@ function ensureWebFontStylesheet() {
56
87
  * @expo-google-fonts/inter) so StyledText's weight range resolves to real
57
88
  * font files. Web loads Inter from Google Fonts as a single CSS family;
58
89
  * weight differentiation there comes from a numeric fontWeight instead.
90
+ *
91
+ * A host app that overrides the sans-serif families via `setFonts` owns
92
+ * loading its own faces (typically through `expo-font`), so the Inter fetch
93
+ * is skipped entirely — nothing would reference those files. For the skip to
94
+ * apply, call `setFonts` before this hook mounts (module scope or ahead of
95
+ * rendering the root); a later call still re-skins text, it just doesn't
96
+ * un-download Inter. The Feather icon font always loads.
59
97
  */
60
98
  export const useResources = () => {
99
+ // Render-body (not effect) so server-web registers the icon font in *this*
100
+ // request's expo-font store — effects never run on the server and a
101
+ // module-scope call only reaches one request. See ensureIconFontRegistered.
102
+ ensureIconFontRegistered();
61
103
  const [loaded, setLoaded] = useState(false);
62
104
  const [error, setError] = useState(null);
63
105
  useEffect(() => {
64
106
  let timeoutId;
107
+ // Read once at mount, not subscribed: font loading is a one-shot startup
108
+ // effect and cannot be undone by a later override.
109
+ const sansSerifOverridden = !!useThemeStore.getState().fontOverrides.families?.sansSerif;
65
110
  async function loadResourcesAndDataAsync() {
66
111
  try {
67
112
  const fontPromise = Promise.all([
68
113
  Font.loadAsync(Feather.font),
69
- loadNativeInterFonts(),
70
- ensureWebFontStylesheet(),
114
+ sansSerifOverridden ? Promise.resolve() : loadNativeInterFonts(),
115
+ sansSerifOverridden ? Promise.resolve() : ensureWebFontStylesheet(),
71
116
  ]);
72
117
  // Timeout after 5 seconds — proceed with system fallback fonts
73
118
  const timeoutPromise = new Promise((_, reject) => {
@@ -20,6 +20,27 @@ export type ColorOverrides = {
20
20
  light?: Partial<ThemeColors>;
21
21
  dark?: Partial<ThemeColors>;
22
22
  };
23
+ /**
24
+ * Shape overrides a host app can inject, grouped per component so future
25
+ * shape knobs (card radius, input radius, …) slot in without reshaping the
26
+ * API. Every field is optional; omitted fields keep the package default.
27
+ */
28
+ export type ShapeOverrides = {
29
+ button?: {
30
+ /**
31
+ * Border radius applied to every Button preset. Package default:
32
+ * `spacing.radiusMd` (12). Use 9999 for pill buttons. A caller `style`
33
+ * still wins over this, as it always has.
34
+ */
35
+ borderRadius?: number;
36
+ /**
37
+ * Whether the `default` preset renders its shadow. Package default: true.
38
+ * Other presets stay flat regardless; the per-instance `withShadow` prop
39
+ * still wins over this.
40
+ */
41
+ withShadow?: boolean;
42
+ };
43
+ };
23
44
  export type ThemeStore = {
24
45
  userTheme: ThemePreference;
25
46
  systemTheme: ResolvedTheme;
@@ -35,6 +56,12 @@ export type ThemeStore = {
35
56
  * exactly as before this field existed (fully backward compatible).
36
57
  */
37
58
  fontOverrides: FontOverrides;
59
+ /**
60
+ * App-injected shape overrides (button radius, default-preset shadow).
61
+ * Same contract as the other override slots: empty by default, fully
62
+ * backward compatible when unset.
63
+ */
64
+ shapeOverrides: ShapeOverrides;
38
65
  setTheme: (theme: ThemePreference) => void;
39
66
  setSystemTheme: (theme: ResolvedTheme) => void;
40
67
  /**
@@ -52,6 +79,11 @@ export type ThemeStore = {
52
79
  * `webWeightStrategy: "family"` — see `FontWeightStrategy`.
53
80
  */
54
81
  setFonts: (overrides: FontOverrides) => void;
82
+ /**
83
+ * Replace the active shape overrides. Pass `{}` to clear them and fall back
84
+ * to the package defaults (button radius 12, default-preset shadow on).
85
+ */
86
+ setShape: (overrides: ShapeOverrides) => void;
55
87
  loadTheme: () => void;
56
88
  };
57
89
  export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
@@ -22,12 +22,17 @@ export const useThemeStore = create((set) => ({
22
22
  // Same contract as `colorOverrides`: the package renders with its bundled
23
23
  // faces until a host app calls `setFonts`.
24
24
  fontOverrides: {},
25
+ // And again for shape: package geometry until a host app calls `setShape`.
26
+ shapeOverrides: {},
25
27
  setColors: (overrides) => {
26
28
  set({ colorOverrides: overrides ?? {} });
27
29
  },
28
30
  setFonts: (overrides) => {
29
31
  set({ fontOverrides: overrides ?? {} });
30
32
  },
33
+ setShape: (overrides) => {
34
+ set({ shapeOverrides: overrides ?? {} });
35
+ },
31
36
  setTheme: (theme) => {
32
37
  set({
33
38
  userTheme: theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [