@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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,124 @@
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.17.0]
7
+
8
+ ### Added
9
+
10
+ - **`mono` font variant.** `StyledText` accepts `variant="mono"` (plus a
11
+ `MonoText` convenience alias) for code, IDs, and tabular figures. Defaults
12
+ to the platform's system monospace (Menlo on iOS, `monospace` on Android, a
13
+ `ui-monospace` stack on web); host apps swap in their own faces via the new
14
+ `mono` group in `setFonts`.
15
+
16
+ - **Per-weight-partial font override groups.** `setFonts` families are now
17
+ partial at both levels: per group (`sansSerif` alone leaves `serif`/`mono`
18
+ at their defaults, as before) and per weight — weights missing from an
19
+ overridden group fall back to that group's `regular`, never to a package
20
+ face. Registering only Regular + Medium is enough. `serif` also gained the
21
+ full weight range (previously `regular`/`bold` only, and `SerifText`
22
+ ignored the requested weight; it now resolves per weight).
23
+
24
+ - **`useResources` skips the Inter download when `sansSerif` is overridden.**
25
+ A host app that forwards its own sans faces owns loading them; nothing
26
+ would reference the packaged Inter, so the native `.ttf` fetch and the web
27
+ Google-Fonts stylesheet are skipped. Call `setFonts` before the hook mounts
28
+ for the skip to apply; the Feather icon font always loads. Without
29
+ overrides, loading is unchanged.
30
+
31
+ - **`setShape` — shape injection, completing the `setColors`/`setFonts`
32
+ trio.** Grouped per component for future growth; currently covers Button:
33
+
34
+ ```ts
35
+ useThemeStore.getState().setShape({
36
+ button: { borderRadius: 9999, withShadow: false },
37
+ });
38
+ ```
39
+
40
+ `borderRadius` applies to every Button preset (package default stays 12);
41
+ `withShadow` controls the `default` preset's resting shadow (default stays
42
+ on). Precedence is caller-wins throughout: the per-instance `withShadow`
43
+ prop and caller `style` beat the global override, which beats the package
44
+ default. `setShape({})` clears back to the defaults.
45
+
46
+ - **Every text-rendering component now resolves families through the theme
47
+ store.** Button labels, Label, Switch track labels, InputOTP cells, and
48
+ TextInput previously hardcoded Inter family constants in their static
49
+ styles, so `setFonts` re-skinned `StyledText` but left control chrome on
50
+ Inter. They now share one resolver (`resolveFontStyle`, surfaced to
51
+ components as the `useFontStyle` hook, both exported), so a `setFonts` call
52
+ re-skins the whole package at once.
53
+
54
+ - Exported `FontVariant`, `FontFamilyOverride`, `ResolvedFontStyle`,
55
+ `resolveFontStyle` (from `constants`), `useFontStyle` (from `hooks`),
56
+ `ShapeOverrides` (from `state`), and `MonoText` (from `components`).
57
+
58
+ ### Why
59
+
60
+ A consuming app rethemed to a new design language (custom sans/serif/mono
61
+ faces, pill buttons, flat surfaces) and hit the package's remaining hardcoded
62
+ Inter references, the fixed button radius, and the always-on default-preset
63
+ shadow — none of which were themable. Fully backward compatible: with no
64
+ `setFonts`/`setShape` call, fonts, radii, and shadows are identical to 0.16.0.
65
+
66
+ ## [0.16.0]
67
+
68
+ ### Added
69
+
70
+ - **`setFonts` — font injection, mirroring `setColors`.** A host app can now
71
+ forward its own bundled faces to the package:
72
+
73
+ ```ts
74
+ useThemeStore.getState().setFonts({
75
+ families: { sansSerif: { light: "…", regular: "…", medium: "…",
76
+ semibold: "…", bold: "…" } },
77
+ webWeightStrategy: "family",
78
+ });
79
+ ```
80
+
81
+ Overrides are partial and per-group — pass `sansSerif` alone and `serif`
82
+ keeps the package default. `setFonts({})` clears back to the defaults.
83
+ `StyledText` subscribes to the store, so a call after mount re-renders
84
+ rather than leaving stale families on screen.
85
+
86
+ - **`webWeightStrategy` — `"numeric" | "family"`.** Declares how a family map
87
+ expresses weight. `"numeric"` (the package default on web) assumes one
88
+ multi-weight CSS family where a numeric `fontWeight` selects the
89
+ `@font-face` variant. `"family"` means each weight is its own
90
+ separately-registered single-face family, so the family name alone carries
91
+ the weight and `StyledText` suppresses the numeric `fontWeight`. Native is
92
+ always `"family"`, unchanged.
93
+
94
+ Apps loading per-weight faces through `expo-font` need `"family"` on web:
95
+ `expo-font` registers each file as its own family there too, so the previous
96
+ always-numeric behaviour synthesised a second layer of bold on top of an
97
+ already-bold face.
98
+
99
+ - Exported `FontFamilyMap`, `FontFamilyWeight`, `FontOverrides`,
100
+ `FontWeightStrategy`, and `defaultWebWeightStrategy` from `constants/fonts`.
101
+
102
+ - **`createThemedStyles` — SSR-safe themed style registration**, exported from
103
+ `lib`. Wraps a `(theme: Theme) => styles` factory so both base themes are
104
+ registered with react-native-web at module scope — before expo-router
105
+ snapshots the server-side stylesheet — instead of lazily during render via
106
+ `useMemo`, which left SSR HTML referencing class names with no rules
107
+ (unstyled first paint until hydration). Override themes are cached per theme
108
+ object identity. The package's own components (Badge, Button, Card,
109
+ EmptyState, InputOTP, Label, Notification, Skeleton, TextInput) now use it.
110
+
111
+ ### Why
112
+
113
+ `fontFamilies` was a module-level `const` with no injection point, so the only
114
+ way for a consumer to use its own type was to patch `dist/` inside
115
+ `node_modules`. Bun keys `patchedDependencies` on an exact `name@version`, so
116
+ those patches stop applying — silently, with no warning — the moment the
117
+ package version is bumped. Two consumer apps hit exactly that and shipped
118
+ fallback fonts without noticing. Colors already had `setColors` for this
119
+ reason; fonts now match.
120
+
121
+ Fully backward compatible: with no `setFonts` call, font resolution is
122
+ identical to 0.15.0.
123
+
6
124
  ## [0.15.0]
7
125
 
8
126
  ### Added
package/LLM_USAGE.md CHANGED
@@ -164,6 +164,15 @@ React context, scoped keys win over the global brand inside it, and nested
164
164
  scopes merge (inner wins, outer fills in). With no override at either layer,
165
165
  `useTheme()` returns the base theme by reference.
166
166
 
167
+ Fonts and shape have matching global injection points. `setFonts({ families:
168
+ { sansSerif?, serif?, mono? }, webWeightStrategy? })` replaces the bundled
169
+ faces (Inter/Georgia/system-mono) everywhere text renders; groups and weights
170
+ are partial, missing weights fall back to the group's `regular`, and an
171
+ overridden `sansSerif` makes `useResources` skip downloading Inter (call
172
+ `setFonts` before mount for the skip). `setShape({ button: { borderRadius?,
173
+ withShadow? } })` re-shapes Buttons globally (e.g. pill radius 9999, shadow
174
+ off). Per-instance props and caller `style` always win over both.
175
+
167
176
  ## Component Use-Case Index
168
177
 
169
178
  Use this table before creating a new app-local primitive.
package/README.md CHANGED
@@ -163,6 +163,92 @@ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
163
163
  </ThemeColorScope>
164
164
  ```
165
165
 
166
+ ### Font overrides
167
+
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:
172
+
173
+ ```tsx
174
+ import { useThemeStore } from "@mrmeg/expo-ui/state";
175
+
176
+ useThemeStore.getState().setFonts({
177
+ families: {
178
+ sansSerif: {
179
+ regular: "HankenGrotesk_400Regular",
180
+ medium: "HankenGrotesk_500Medium",
181
+ semibold: "HankenGrotesk_600SemiBold",
182
+ bold: "HankenGrotesk_700Bold",
183
+ },
184
+ serif: {
185
+ regular: "Newsreader_400Regular",
186
+ semibold: "Newsreader_600SemiBold",
187
+ },
188
+ mono: {
189
+ regular: "JetBrainsMono_400Regular",
190
+ medium: "JetBrainsMono_500Medium",
191
+ },
192
+ },
193
+ webWeightStrategy: "family",
194
+ });
195
+ ```
196
+
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.
213
+
214
+ **Pick the right `webWeightStrategy`** — this is the one non-obvious part:
215
+
216
+ | Strategy | Use when | Effect |
217
+ |---|---|---|
218
+ | `"numeric"` (default) | You ship **one multi-weight CSS family** (a variable font, or Google Fonts' single `"Inter"` family) | A numeric `fontWeight` selects the `@font-face` variant |
219
+ | `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
220
+
221
+ If you load fonts through `expo-font` / `@expo-google-fonts`, choose
222
+ `"family"`. `expo-font` registers each weight as its own single-face family on
223
+ web as well as native, so emitting a numeric weight on top makes the browser
224
+ synthesise a *second* layer of bold over an already-bold face.
225
+
226
+ Native always behaves as `"family"` regardless of this setting.
227
+
228
+ > Prefer `setFonts` over patching `node_modules`. Bun keys
229
+ > `patchedDependencies` to an exact `name@version`, so a font patch silently
230
+ > stops applying the next time you bump the package — with no warning.
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
+
166
252
  Use `StyledText` for theme-aware text:
167
253
 
168
254
  ```tsx
@@ -179,7 +265,8 @@ Useful `StyledText` props:
179
265
  - `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`
180
266
  - `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
181
267
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
182
- - `variant`: `sansSerif`, `serif`
268
+ - `variant`: `sansSerif`, `serif`, `mono` (or the `MonoText` alias for code,
269
+ IDs, and tabular figures)
183
270
  - `align`, `tx`, `txOptions`
184
271
  - `selectable`: defaults to `true` for readable copy; package controls disable
185
272
  selection for labels and other interactive chrome where accidental drag
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useMemo } from "react";
2
+ import React from "react";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { StyledText } from "./StyledText.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
7
8
  /**
8
9
  * Badge Component
9
10
  *
@@ -19,7 +20,7 @@ import { StyledText } from "./StyledText.js";
19
20
  */
20
21
  function Badge({ children, text, variant = "default", style: styleOverride }) {
21
22
  const { theme } = useTheme();
22
- const styles = useMemo(() => createStyles(theme), [theme]);
23
+ const styles = themedStyles(theme);
23
24
  const badgeContent = text ?? children;
24
25
  const textStyle = [
25
26
  styles.text,
@@ -74,4 +75,5 @@ const createStyles = (theme) => StyleSheet.create({
74
75
  userSelect: "none",
75
76
  },
76
77
  });
78
+ const themedStyles = createThemedStyles(createStyles);
77
79
  export { Badge };
@@ -5,10 +5,12 @@ 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";
13
+ import { createThemedStyles } from "../lib/themedStyles.js";
12
14
  const SIZE_CONFIGS = {
13
15
  sm: {
14
16
  paddingVertical: spacing.xxs,
@@ -72,14 +74,24 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
72
74
  function ButtonRoot(props) {
73
75
  const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
74
76
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
75
- const styles = useMemo(() => createStyles(theme, size), [theme, size]);
77
+ const styles = themedStyles(theme)[size];
76
78
  const shadowStyle = getShadowStyle("subtle");
77
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);
78
82
  // Filled (`default`) buttons float by default; ghost/outline/link stay flat
79
83
  // (transparent background reads oddly with a shadow), and destructive/secondary
80
- // keep the old opt-in behavior. Callers can still override via `withShadow`.
81
- 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);
82
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]);
83
95
  // Pre-compute background color for contrast calculation
84
96
  // Always flatten to handle both array styles (from Slot) and RegisteredStyle
85
97
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
@@ -169,8 +181,12 @@ function ButtonRoot(props) {
169
181
  return nextWidth;
170
182
  });
171
183
  }, [fullWidth, loading]);
172
- 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: [
173
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 },
174
190
  preset === "default" && styles.buttonDefault,
175
191
  preset === "outline" && styles.buttonOutline,
176
192
  preset === "ghost" && styles.buttonGhost,
@@ -190,7 +206,7 @@ function ButtonRoot(props) {
190
206
  // Spread array styles from Slot to prevent nested arrays on web
191
207
  ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
192
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: [
193
- styles.text,
209
+ labelTextStyle,
194
210
  state.pressed && styles.pressedText,
195
211
  state.pressed && pressedTextStyleOverride,
196
212
  isDisabled && disabledTextStyleOverride,
@@ -282,15 +298,11 @@ const createStyles = (theme, size) => {
282
298
  width: "100%",
283
299
  },
284
300
  text: {
285
- // Button labels render at medium weight. On native, the family itself
286
- // carries the weight (a real static Inter_500Medium file) — pairing it
287
- // with a numeric fontWeight would faux-bold on top of that file. Web
288
- // shares one "Inter" family across weights, so it needs the numeric
289
- // fontWeight to pick the right @font-face variant. (Same rule as
290
- // StyledText's getFontFamilyWeight / WEB_FONT_WEIGHTS.)
291
- 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.
292
305
  fontSize: sizeConfig.fontSize,
293
- ...(Platform.OS === "web" && { fontWeight: "500" }),
294
306
  textAlign: "center",
295
307
  lineHeight: sizeConfig.fontSize * 1.4,
296
308
  flexShrink: 0,
@@ -321,6 +333,11 @@ const createStyles = (theme, size) => {
321
333
  },
322
334
  });
323
335
  };
336
+ const themedStyles = createThemedStyles((theme) => ({
337
+ sm: createStyles(theme, "sm"),
338
+ md: createStyles(theme, "md"),
339
+ lg: createStyles(theme, "lg"),
340
+ }));
324
341
  /**
325
342
  * Button with explicit subcomponents.
326
343
  * - `Button.Text` for label text (inherits control typography)
@@ -5,6 +5,7 @@ import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { useScalePress } from "../hooks/useScalePress.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
9
  /**
9
10
  * Card Component
10
11
  *
@@ -30,15 +31,15 @@ import { spacing } from "../constants/spacing.js";
30
31
  const CardContext = createContext(null);
31
32
  function useCardContext() {
32
33
  const ctx = use(CardContext);
33
- // useTheme must run unconditionally (Rules of Hooks); createCardStyles is a
34
+ // useTheme must run unconditionally (Rules of Hooks); themedStyles is a
34
35
  // plain function, so the `??` keeps the fallback styles lazy.
35
36
  const { theme } = useTheme();
36
37
  // Fallback for standalone usage without a Card parent.
37
- return ctx ?? { theme, styles: createCardStyles(theme) };
38
+ return ctx ?? { theme, styles: themedStyles(theme) };
38
39
  }
39
40
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
40
41
  const { theme, getShadowStyle } = useTheme();
41
- const styles = createCardStyles(theme);
42
+ const styles = themedStyles(theme);
42
43
  const shadowStyle = getShadowStyle("subtle");
43
44
  const ctx = { theme, styles };
44
45
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
@@ -122,4 +123,5 @@ const createCardStyles = (theme) => StyleSheet.create({
122
123
  lineHeight: 20,
123
124
  },
124
125
  });
126
+ const themedStyles = createThemedStyles(createCardStyles);
125
127
  export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
2
  import { View, StyleSheet } from "react-native";
4
3
  import { SansSerifBoldText, SansSerifText } from "./StyledText.js";
5
4
  import { Button } from "./Button.js";
6
5
  import { Icon } from "./Icon.js";
7
6
  import { useTheme } from "../hooks/useTheme.js";
8
7
  import { spacing } from "../constants/spacing.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
9
  /**
10
10
  * EmptyState Component
11
11
  *
@@ -25,7 +25,7 @@ import { spacing } from "../constants/spacing.js";
25
25
  */
26
26
  export function EmptyState({ icon, iconSize = spacing.iconMd, title, description, actionLabel, onAction, actionPreset = "default", style, children, bordered = false, }) {
27
27
  const { theme } = useTheme();
28
- const styles = useMemo(() => createStyles(theme), [theme]);
28
+ const styles = themedStyles(theme);
29
29
  return (_jsxs(View, { style: [styles.container, bordered && styles.bordered, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, size: "lg", style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, size: "base", style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
30
30
  }
31
31
  const MEDIA_SIZE = 56;
@@ -74,3 +74,4 @@ const createStyles = (theme) => StyleSheet.create({
74
74
  alignSelf: "center",
75
75
  },
76
76
  });
77
+ const themedStyles = createThemedStyles(createStyles);
@@ -1,14 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useRef, useState, useCallback } from "react";
2
+ import { useRef, useState, useCallback } from "react";
3
3
  import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
- import { fontFamilies } from "../constants/fonts.js";
7
6
  import { StyledText } from "./StyledText.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
8
  const CELL_WIDTH = 36;
9
9
  const CELL_HEIGHT = 40;
10
10
  const CELL_FONT_SIZE = 20;
11
- const CELL_FONT_WEIGHT = "600";
12
11
  const BULLET = "\u2022";
13
12
  /**
14
13
  * OTP/verification code input with individual character cells.
@@ -30,7 +29,7 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
30
29
  const { theme } = useTheme();
31
30
  const inputRef = useRef(null);
32
31
  const [focused, setFocused] = useState(false);
33
- const styles = useMemo(() => createStyles(theme), [theme]);
32
+ const styles = themedStyles(theme);
34
33
  const hasError = error || !!errorText;
35
34
  // Active cell index: next empty cell, or last cell when full
36
35
  const activeIndex = Math.min(value.length, length - 1);
@@ -97,14 +96,8 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
97
96
  opacity: disabled ? 0.5 : 1,
98
97
  borderColor,
99
98
  },
100
- ], children: _jsx(StyledText, { selectable: false, style: {
99
+ ], children: _jsx(StyledText, { selectable: false, fontWeight: "semibold", style: {
101
100
  fontSize: CELL_FONT_SIZE,
102
- // Semibold weight: the family carries it on native (a real static
103
- // Inter_600SemiBold file); web shares one "Inter" family across
104
- // weights, so it needs the numeric fontWeight to pick the right
105
- // @font-face variant (same rule as StyledText's WEB_FONT_WEIGHTS).
106
- ...(Platform.OS === "web" && { fontWeight: CELL_FONT_WEIGHT }),
107
- fontFamily: fontFamilies.sansSerif.semibold,
108
101
  color: theme.colors.text,
109
102
  textAlign: "center",
110
103
  lineHeight: CELL_FONT_SIZE * 1.2,
@@ -126,10 +119,12 @@ const createStyles = (theme) => StyleSheet.create({
126
119
  gap: spacing.sm,
127
120
  },
128
121
  errorText: {
122
+ // Regular weight — StyledText's default resolves the family through the
123
+ // theme store, so no hardcoded fontFamily here.
129
124
  fontSize: 12,
130
- fontFamily: fontFamilies.sansSerif.regular,
131
125
  color: theme.colors.destructive,
132
126
  marginTop: spacing.xs,
133
127
  },
134
128
  });
129
+ const themedStyles = createThemedStyles(createStyles);
135
130
  export { InputOTP };
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
2
  import { StyleSheet, Platform } from "react-native";
4
3
  import * as LabelPrimitive from "@rn-primitives/label";
5
4
  import { useTheme } from "../hooks/useTheme.js";
5
+ import { useFontStyle } from "../hooks/useFontStyle.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
- import { fontFamilies } from "../constants/fonts.js";
8
7
  import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
9
  const SIZE_CONFIGS = {
10
10
  sm: { fontSize: 12 },
11
11
  md: { fontSize: 14 },
@@ -37,10 +37,14 @@ const SIZE_CONFIGS = {
37
37
  */
38
38
  export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
39
39
  const { theme } = useTheme();
40
- const styles = useMemo(() => createStyles(theme), [theme]);
40
+ const styles = themedStyles(theme);
41
41
  const sizeConfig = SIZE_CONFIGS[size];
42
+ // 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: {
@@ -78,3 +79,4 @@ const createStyles = (theme) => StyleSheet.create({
78
79
  opacity: 0.6,
79
80
  },
80
81
  });
82
+ const themedStyles = createThemedStyles(createStyles);
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useCallback, use, useEffect, useEffectEvent, useRef } from "react";
2
+ import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
3
3
  import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
4
4
  import { SafeAreaInsetsContext } from "react-native-safe-area-context";
5
5
  import { Icon } from "./Icon.js";
@@ -10,6 +10,7 @@ import { durations } from "../constants/motion.js";
10
10
  import { StyledText } from "./StyledText.js";
11
11
  import { palette } from "../constants/colors.js";
12
12
  import { translateText } from "../lib/i18n.js";
13
+ import { createThemedStyles } from "../lib/themedStyles.js";
13
14
  import { globalUIStore } from "../state/globalUIStore.js";
14
15
  const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
15
16
  const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
@@ -36,7 +37,7 @@ export const Notification = () => {
36
37
  const reduceMotion = useReducedMotion();
37
38
  const insets = use(SafeAreaInsetsContext);
38
39
  const { alert, hide } = globalUIStore();
39
- const styles = useMemo(() => createStyles(theme), [theme]);
40
+ const styles = themedStyles(theme);
40
41
  const position = alert?.position ?? "top";
41
42
  const isBottom = position === "bottom";
42
43
  // Just opacity + translateY — no scale (scale = bouncy feel)
@@ -301,3 +302,4 @@ const createStyles = (theme) => StyleSheet.create({
301
302
  ...(Platform.OS === "web" && { cursor: "pointer" }),
302
303
  },
303
304
  });
305
+ const themedStyles = createThemedStyles(createStyles);
@@ -4,6 +4,7 @@ import { View, StyleSheet, Animated } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
7
8
  /**
8
9
  * Skeleton Component
9
10
  *
@@ -72,7 +73,7 @@ export function SkeletonAvatar({ size = 40, style }) {
72
73
  */
73
74
  export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
74
75
  const { theme, getShadowStyle } = useTheme();
75
- const styles = createCardStyles(theme);
76
+ const styles = themedStyles(theme);
76
77
  return (_jsxs(View, { style: [styles.card, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
77
78
  }
78
79
  const createCardStyles = (theme) => StyleSheet.create({
@@ -97,3 +98,4 @@ const createCardStyles = (theme) => StyleSheet.create({
97
98
  gap: spacing.xs,
98
99
  },
99
100
  });
101
+ const themedStyles = createThemedStyles(createCardStyles);
@@ -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