@mrmeg/expo-ui 0.15.0 → 0.17.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.
@@ -1,8 +1,9 @@
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 } from "../constants/fonts.js";
5
+ import { resolveFontStyle } 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 = {
@@ -54,22 +55,6 @@ const SEMANTIC_CONFIGS = {
54
55
  // and the textTransform applied in StyledText below.
55
56
  eyebrow: { size: "sm", weight: "semibold" },
56
57
  };
57
- // Font family weight keys now map 1:1 onto FontWeight — every weight has a
58
- // real family entry (native: a distinct static Inter file; web: shared
59
- // "Inter" family + numeric fontWeight, see WEB_FONT_WEIGHTS). Defaults to
60
- // "regular" when no weight is requested.
61
- const getFontFamilyWeight = (weight) => weight ?? "regular";
62
- // Web-only: the numeric fontWeight that picks the right @font-face variant
63
- // out of the single shared "Inter" CSS family. "light" has no loaded 300
64
- // weight (mirrors the native light->regular mapping in constants/fonts.ts),
65
- // so it renders at 400 like "regular".
66
- const WEB_FONT_WEIGHTS = {
67
- light: "400",
68
- regular: "400",
69
- medium: "500",
70
- semibold: "600",
71
- bold: "700",
72
- };
73
58
  /**
74
59
  * Base Text component with theme and variant support
75
60
  * Uses theme colors automatically - text by default
@@ -89,6 +74,9 @@ const WEB_FONT_WEIGHTS = {
89
74
  export function StyledText(props) {
90
75
  const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, size, semantic, align, selectable, children, ref, ...otherProps } = props;
91
76
  const { theme } = useTheme();
77
+ // Subscribed, not read via getState(), so a `setFonts` call after mount
78
+ // re-renders text instead of leaving stale families on screen.
79
+ const fontOverrides = useThemeStore((s) => s.fontOverrides);
92
80
  // Check if there's a color override from parent context (e.g., Button)
93
81
  const contextColor = use(TextColorContext);
94
82
  const contextTextStyle = use(TextStyleContext);
@@ -100,18 +88,12 @@ export function StyledText(props) {
100
88
  const semanticConfig = semantic ? SEMANTIC_CONFIGS[semantic] : undefined;
101
89
  const finalSize = semanticConfig?.size ?? size ?? "body";
102
90
  const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
103
- // Get font family based on variant and weight
104
- const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
105
- // Georgia (serif) only has one weight — use it regardless of requested weight
106
- 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;
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);
115
97
  // Get fontSize and lineHeight from size variant
116
98
  const fontSize = FONT_SIZES[finalSize];
117
99
  const lineHeight = LINE_HEIGHTS[finalSize];
@@ -160,6 +142,15 @@ export function SerifText(props) {
160
142
  export function SansSerifText(props) {
161
143
  return _jsx(StyledText, { ...props, variant: "sansSerif" });
162
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
+ }
163
154
  /**
164
155
  * Serif Bold Text Component
165
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,10 +4,11 @@ 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";
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,8 @@ 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];
96
+ const inputFont = useFontStyle("regular");
95
97
  const [focused, setFocused] = useState(false);
96
98
  const [contentHeight, setContentHeight] = useState(0);
97
99
  const [passwordVisible, setPasswordVisible] = useState(false);
@@ -148,6 +150,9 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
148
150
  };
149
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: [
150
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,
151
156
  {
152
157
  backgroundColor,
153
158
  borderColor,
@@ -197,7 +202,8 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
197
202
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
203
  leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
199
204
  const { theme, getContrastingColor } = useTheme();
200
- const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
205
+ const styles = themedStyles(theme)[variant][size];
206
+ const inputFont = useFontStyle("regular");
201
207
  const [passwordVisible, setPasswordVisible] = useState(false);
202
208
  const hasError = error || !!errorText;
203
209
  const sizeConfig = SIZE_CONFIGS[size];
@@ -318,10 +324,12 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
318
324
  // @expo/ui passes the family verbatim to SwiftUI's Font.custom / Compose,
319
325
  // where no such font exists — the fallback ignores fontSize and renders at
320
326
  // the 17pt default, blowing up text and secure-entry dots. Omit the family
321
- // so the native side uses the system font at our requested size.
322
- 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"
323
331
  ? undefined
324
- : fontFamilies.sansSerif.regular;
332
+ : inputFont.fontFamily;
325
333
  const textStyle = {
326
334
  color: textColor,
327
335
  fontSize: sizeConfig.fontSize,
@@ -352,7 +360,8 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
352
360
  justifyContent: "center",
353
361
  },
354
362
  input: {
355
- fontFamily: fontFamilies.sansSerif.regular,
363
+ // fontFamily is resolved per-render through the theme store (see
364
+ // `useFontStyle("regular")` in WebTextInput) so `setFonts` applies.
356
365
  borderRadius: spacing.radiusMd,
357
366
  borderWidth: 1,
358
367
  ...(Platform.OS === "web" && { outlineStyle: "none" }),
@@ -443,3 +452,9 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
443
452
  zIndex: 1,
444
453
  },
445
454
  });
455
+ const VARIANT_KEYS = ["outline", "filled", "underlined"];
456
+ const SIZE_KEYS = ["sm", "md", "lg"];
457
+ const themedStyles = createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
458
+ variant,
459
+ Object.fromEntries(SIZE_KEYS.map((size) => [size, createStyles(theme, variant, size)])),
460
+ ])));
@@ -9,19 +9,80 @@ interface NavigationFonts {
9
9
  bold: FontStyle;
10
10
  heavy: FontStyle;
11
11
  }
12
- export declare const fontFamilies: {
13
- serif: {
14
- regular: string;
15
- bold: string;
16
- };
17
- sansSerif: {
18
- light: string;
19
- regular: string;
20
- medium: string;
21
- semibold: string;
22
- bold: string;
23
- };
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
+ /** 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>>;
39
+ /**
40
+ * Font overrides a host app can inject to brand the package.
41
+ *
42
+ * Mirrors `setColors`: the package ships neutral defaults, but a consuming app
43
+ * almost always bundles its own faces. Without an injection point, package
44
+ * components render the package's fonts while app-authored siblings render the
45
+ * app's — and the only remaining lever is patching `node_modules`, which is
46
+ * keyed to an exact version and silently stops applying on the next bump.
47
+ *
48
+ * Partial by design: override `sansSerif` alone and `serif`/`mono` keep their
49
+ * defaults.
50
+ */
51
+ export type FontOverrides = {
52
+ families?: Partial<Record<FontVariant, FontFamilyOverride>>;
53
+ /** Defaults to the package's own per-platform behaviour when omitted. */
54
+ webWeightStrategy?: FontWeightStrategy;
24
55
  };
56
+ /** The package's built-in families. Override per-app via `setFonts`. */
57
+ export declare const fontFamilies: FontFamilyMap;
58
+ /**
59
+ * The package's own web weight strategy: one multi-weight "Inter" family, so
60
+ * weight comes from a numeric `fontWeight`. Native always behaves as
61
+ * `"family"` regardless of this value.
62
+ */
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;
25
86
  /**
26
87
  * Typography scale — Tailwind-matching sizes for consistent text hierarchy.
27
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
@@ -42,10 +78,60 @@ const sansSerifFamilies = isWebRuntime
42
78
  semibold: "sans-serif",
43
79
  bold: "sans-serif",
44
80
  };
81
+ /** The package's built-in families. Override per-app via `setFonts`. */
45
82
  export const fontFamilies = {
46
83
  serif: serifFamilies,
47
84
  sansSerif: sansSerifFamilies,
85
+ mono: monoFamilies,
86
+ };
87
+ /**
88
+ * The package's own web weight strategy: one multi-weight "Inter" family, so
89
+ * weight comes from a numeric `fontWeight`. Native always behaves as
90
+ * `"family"` regardless of this value.
91
+ */
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",
48
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
+ }
49
135
  export const typography = {
50
136
  xs: { fontSize: 12, lineHeight: 16 },
51
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
+ }
@@ -9,6 +9,13 @@ interface LoadResourcesResult {
9
9
  * @expo-google-fonts/inter) so StyledText's weight range resolves to real
10
10
  * font files. Web loads Inter from Google Fonts as a single CSS family;
11
11
  * weight differentiation there comes from a numeric fontWeight instead.
12
+ *
13
+ * A host app that overrides the sans-serif families via `setFonts` owns
14
+ * loading its own faces (typically through `expo-font`), so the Inter fetch
15
+ * is skipped entirely — nothing would reference those files. For the skip to
16
+ * apply, call `setFonts` before this hook mounts (module scope or ahead of
17
+ * rendering the root); a later call still re-skins text, it just doesn't
18
+ * un-download Inter. The Feather icon font always loads.
12
19
  */
13
20
  export declare const useResources: () => LoadResourcesResult;
14
21
  export {};
@@ -3,6 +3,7 @@ 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
+ import { useThemeStore } from "../state/themeStore.js";
6
7
  // Eager, module-scope load so expo-font registers Feather in its SSR
7
8
  // serverContext. When the server renders the HTML document, expo-font emits
8
9
  // an @font-face <style> into the head — without this kickoff, server-rendered
@@ -56,18 +57,28 @@ function ensureWebFontStylesheet() {
56
57
  * @expo-google-fonts/inter) so StyledText's weight range resolves to real
57
58
  * font files. Web loads Inter from Google Fonts as a single CSS family;
58
59
  * weight differentiation there comes from a numeric fontWeight instead.
60
+ *
61
+ * A host app that overrides the sans-serif families via `setFonts` owns
62
+ * loading its own faces (typically through `expo-font`), so the Inter fetch
63
+ * is skipped entirely — nothing would reference those files. For the skip to
64
+ * apply, call `setFonts` before this hook mounts (module scope or ahead of
65
+ * rendering the root); a later call still re-skins text, it just doesn't
66
+ * un-download Inter. The Feather icon font always loads.
59
67
  */
60
68
  export const useResources = () => {
61
69
  const [loaded, setLoaded] = useState(false);
62
70
  const [error, setError] = useState(null);
63
71
  useEffect(() => {
64
72
  let timeoutId;
73
+ // Read once at mount, not subscribed: font loading is a one-shot startup
74
+ // effect and cannot be undone by a later override.
75
+ const sansSerifOverridden = !!useThemeStore.getState().fontOverrides.families?.sansSerif;
65
76
  async function loadResourcesAndDataAsync() {
66
77
  try {
67
78
  const fontPromise = Promise.all([
68
79
  Font.loadAsync(Feather.font),
69
- loadNativeInterFonts(),
70
- ensureWebFontStylesheet(),
80
+ sansSerifOverridden ? Promise.resolve() : loadNativeInterFonts(),
81
+ sansSerifOverridden ? Promise.resolve() : ensureWebFontStylesheet(),
71
82
  ]);
72
83
  // Timeout after 5 seconds — proceed with system fallback fonts
73
84
  const timeoutPromise = new Promise((_, reject) => {
@@ -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
  /**
@@ -19,6 +20,27 @@ export type ColorOverrides = {
19
20
  light?: Partial<ThemeColors>;
20
21
  dark?: Partial<ThemeColors>;
21
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
+ };
22
44
  export type ThemeStore = {
23
45
  userTheme: ThemePreference;
24
46
  systemTheme: ResolvedTheme;
@@ -28,6 +50,18 @@ export type ThemeStore = {
28
50
  * behaves exactly as before this field existed (fully backward compatible).
29
51
  */
30
52
  colorOverrides: ColorOverrides;
53
+ /**
54
+ * App-injected font overrides, applied by `StyledText` on top of the package
55
+ * defaults. Empty by default — zero override means the package behaves
56
+ * exactly as before this field existed (fully backward compatible).
57
+ */
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;
31
65
  setTheme: (theme: ThemePreference) => void;
32
66
  setSystemTheme: (theme: ResolvedTheme) => void;
33
67
  /**
@@ -35,6 +69,21 @@ export type ThemeStore = {
35
69
  * clear overrides and fall back to the package defaults.
36
70
  */
37
71
  setColors: (overrides: ColorOverrides) => void;
72
+ /**
73
+ * Replace the active font overrides. Pass `{}` to clear them and fall back
74
+ * to the package's bundled faces.
75
+ *
76
+ * Call this once at startup, after the app's fonts have been registered
77
+ * (e.g. alongside `setColors` in a theme-sync component). Apps loading
78
+ * per-weight faces via `expo-font` should also pass
79
+ * `webWeightStrategy: "family"` — see `FontWeightStrategy`.
80
+ */
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;
38
87
  loadTheme: () => void;
39
88
  };
40
89
  export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;