@mrmeg/expo-ui 0.23.0 → 0.24.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/README.md CHANGED
@@ -1,44 +1,30 @@
1
1
  # @mrmeg/expo-ui
2
2
 
3
- Reusable Expo and React Native UI primitives shared by the template and consumer apps. Typography is Inter: native consumers load four static Inter weights via `@expo-google-fonts/inter`, web consumers load Inter from Google Fonts.
4
-
5
- This package is public for installability, reuse across MrMeg projects, and
6
- discoverability. It is a personal reusable Expo / React Native design-system
7
- package, not a generally supported open-source UI library. The package is
8
- published as `UNLICENSED`; use outside MrMeg projects requires explicit
9
- permission.
3
+ Reusable Expo and React Native UI primitives: design tokens, a theme store, and
4
+ a universal component library for iOS, Android, and web. MIT licensed.
10
5
 
11
6
  ## For LLMs And Coding Agents
12
7
 
13
- When this package is installed from npm, read
14
- `node_modules/@mrmeg/expo-ui/llms.txt` first, then
15
- `node_modules/@mrmeg/expo-ui/LLM_USAGE.md` or
16
- `node_modules/@mrmeg/expo-ui/llms-full.md` before creating app-local UI
17
- primitives. Those files are shipped in the npm package and give the short
18
- component-selection rules, import paths, setup requirements, component catalog,
19
- and examples that help agents choose existing package components instead of
20
- rebuilding them.
8
+ Read `node_modules/@mrmeg/expo-ui/llms.txt` first, then `LLM_USAGE.md` (concise
9
+ rules) or `llms-full.md` (expanded contract), before creating app-local UI
10
+ primitives. All three ship in the npm tarball.
21
11
 
22
12
  ## Install
23
13
 
24
- Install from npm after publishing:
25
-
26
14
  ```sh
27
15
  bun add @mrmeg/expo-ui
28
16
  ```
29
17
 
30
- Consumers must also install the native and Expo peer dependencies listed in
31
- `package.json`. The tested compatibility window is Expo SDK 56–57 with React
32
- 19.2, React Native 0.85–0.86, and React Native Web 0.21. UI animations and
33
- keyboard-aware sheet offsets use React Native Animated by default.
34
- `@rn-primitives/*` packages are managed by `@mrmeg/expo-ui` because they are
35
- implementation details of the exported UI components. Native bottom sheet
36
- keyboard avoidance uses React Native keyboard events. i18n setup is optional;
37
- plain text and children render without
38
- `i18next` or `react-i18next`. Start
39
- consumer apps from the same Expo SDK family or update the package and peer
40
- ranges deliberately. Keep npm auth tokens in developer or CI configuration,
41
- not in this repository.
18
+ Tested hosts: Expo SDK 56–57, React 19.2, React Native 0.85–0.86, React Native
19
+ Web 0.21. Install these peers at the versions your Expo SDK recommends: `expo`,
20
+ `@expo/ui`, `expo-font`, `expo-haptics`, `react`, `react-native`,
21
+ `react-native-web`, `react-native-gesture-handler`,
22
+ `react-native-keyboard-controller`, `react-native-safe-area-context`,
23
+ `react-native-screens`, `@react-native-async-storage/async-storage`, `zustand`.
24
+
25
+ `@rn-primitives/*` and `@expo-google-fonts/inter` are package dependencies, not
26
+ peers. Animations use React Native `Animated`. `BottomSheet`, `Slider`, and
27
+ `SegmentedControl` render native `@expo/ui` controls. i18n is optional.
42
28
 
43
29
  ## Imports
44
30
 
@@ -47,21 +33,73 @@ import { Button, StyledText, UIProvider } from "@mrmeg/expo-ui/components";
47
33
  import { Button as ButtonDirect } from "@mrmeg/expo-ui/components/Button";
48
34
  import { colors, spacing, typography } from "@mrmeg/expo-ui/constants";
49
35
  import { colors as colorsDirect } from "@mrmeg/expo-ui/constants/colors";
50
- import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
36
+ import { useResources, useStyles, useTheme } from "@mrmeg/expo-ui/hooks";
51
37
  import { useTheme as useThemeDirect } from "@mrmeg/expo-ui/hooks/useTheme";
52
- import { globalUIStore, notify, useThemeStore } from "@mrmeg/expo-ui/state";
38
+ import { globalUIStore, notify, ThemeColorScope, useThemeStore } from "@mrmeg/expo-ui/state";
53
39
  import { configureExpoUiI18n, hapticLight } from "@mrmeg/expo-ui/lib";
40
+ // The root barrel re-exports the whole public surface:
41
+ import { Button, colors, notify, UIProvider, useTheme } from "@mrmeg/expo-ui";
54
42
  ```
55
43
 
56
- The root barrel also exports the public surface:
44
+ Importable paths: root, `components`, `components/*`, `constants`,
45
+ `constants/*`, `hooks`, `hooks/*`, `state`, `state/*`, `lib`. Never import from
46
+ `dist/*` or a source checkout path.
47
+
48
+ ## App Startup
49
+
50
+ Call `useResources()` once near the Expo app root before hiding the splash
51
+ screen, and mount `UIProvider` once near the root. On native, mount
52
+ `react-native-keyboard-controller`'s `KeyboardProvider` above `UIProvider` so
53
+ the package's root keyboard avoidance works.
57
54
 
58
55
  ```tsx
59
- import { Button, UIProvider, colors, notify, useTheme } from "@mrmeg/expo-ui";
56
+ import { ThemeProvider } from "expo-router";
57
+ import { KeyboardProvider } from "react-native-keyboard-controller";
58
+ import { UIProvider } from "@mrmeg/expo-ui/components";
59
+ import { colors } from "@mrmeg/expo-ui/constants";
60
+ import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
61
+
62
+ export default function RootLayout() {
63
+ const { scheme } = useTheme();
64
+ const { loaded } = useResources();
65
+
66
+ if (!loaded) return null;
67
+
68
+ return (
69
+ <ThemeProvider
70
+ value={{
71
+ dark: colors[scheme ?? "light"].dark,
72
+ colors: colors[scheme ?? "light"].navigation,
73
+ fonts: colors[scheme ?? "light"].fonts,
74
+ }}
75
+ >
76
+ <KeyboardProvider>
77
+ <UIProvider>
78
+ {/* App navigation goes here. */}
79
+ </UIProvider>
80
+ </KeyboardProvider>
81
+ </ThemeProvider>
82
+ );
83
+ }
60
84
  ```
61
85
 
62
- ## Theme System
86
+ `UIProvider` is required before `Dialog`, `AlertDialog`, `BottomSheet`,
87
+ `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, and package
88
+ notifications.
63
89
 
64
- Use `useTheme()` for theme-aware app styles:
90
+ | `UIProvider` prop | Default | Effect |
91
+ |---|---|---|
92
+ | `notification` | `true` | Mounts the `Notification` renderer for `notify` / `globalUIStore` |
93
+ | `portalHost` | `true` | Mounts the default `@rn-primitives` portal host |
94
+ | `statusBar` | `true` | Mounts the theme-aware `StatusBar` |
95
+ | `keyboardAvoiding` | `true` native, `false` web | Wraps children in `KeyboardAvoidingView` |
96
+ | `keyboardAvoidingProps` | — | Forwarded to that root wrapper |
97
+
98
+ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
99
+ directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
100
+ `keyboardVerticalOffset`).
101
+
102
+ ## Theme System
65
103
 
66
104
  ```tsx
67
105
  import { StyleSheet, View } from "react-native";
@@ -75,11 +113,9 @@ export function Panel() {
75
113
  return (
76
114
  <View
77
115
  style={[
78
- styles.panel,
79
- {
80
- backgroundColor: theme.colors.card,
81
- borderColor: theme.colors.border,
82
- },
116
+ { backgroundColor: theme.colors.card, borderColor: theme.colors.border },
117
+ { borderWidth: StyleSheet.hairlineWidth, borderRadius: spacing.radiusLg },
118
+ { padding: spacing.cardPadding, gap: spacing.sm },
83
119
  getShadowStyle("subtle"),
84
120
  ]}
85
121
  >
@@ -90,27 +126,40 @@ export function Panel() {
90
126
  </View>
91
127
  );
92
128
  }
93
-
94
- const styles = StyleSheet.create({
95
- panel: {
96
- borderWidth: StyleSheet.hairlineWidth,
97
- borderRadius: spacing.radiusLg,
98
- padding: spacing.cardPadding,
99
- gap: spacing.sm,
100
- },
101
- });
102
129
  ```
103
130
 
104
- `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.surfaceSunken`, `background`, `foreground`, `card`, `popover`, `border`, `borderStrong`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
105
-
106
- Surfaces are layered tiers rather than shadow depths: `surfaceSunken` (app chrome, e.g. the Drawer rail) sits below `background` (content), which sits below `card`/`popover` (raised panels), which sit below `muted` (chips and insets). `border` is the hairline for elements on `background`/`card`; use `borderStrong` when the element sits on a filled surface, where `border` would blend into the fill. `textDim`/`mutedForeground` are tuned for readability — at least 7:1 against `background`/`card` and 6:1 against `muted` in both schemes — and a package test enforces those floors.
107
-
108
- When `currentTheme` is `"system"`, the package tracks the OS color scheme and
109
- updates all `useTheme()` and `useStyles()` consumers through the package theme
110
- store. Apps should not add their own Appearance or `matchMedia` listeners just
111
- to make package components follow system light/dark changes.
112
-
113
- Use `useStyles()` when a component needs memoized theme-aware styles. The style factory receives `{ theme, spacing, withAlpha }`, and the returned hook value also includes the normal `useTheme()` helpers.
131
+ `useTheme()` returns the active `theme`, resolved `scheme`, persisted
132
+ `currentTheme`, `setTheme`, `toggleTheme`, `getShadowStyle`,
133
+ `getFocusRingStyle(offset = 2)`, `getContrastingColor`, `getContrastRatio`,
134
+ `getTextColorForBackground`, and `withAlpha`. `getShadowStyle(type)` takes
135
+ `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
136
+ `cardHover`, or `cardSubtle` and returns a cross-platform `boxShadow` (RN 0.85
137
+ and react-native-web 0.21 deprecate the legacy `shadow*` props).
138
+
139
+ Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
140
+ `card`, `cardForeground`, `popover`, `popoverForeground`, `text`, `textDim`,
141
+ `muted`, `mutedForeground`, `border`, `borderStrong`, `input`, `ring`,
142
+ `overlay`, `primary`, `secondary`, `accent`, their `*Foreground` pairs,
143
+ `destructive`, `success`, `warning`. `primary` is the neutral action color,
144
+ `secondary` a neutral secondary surface, `accent` the teal highlight, `input`
145
+ the default form-control border, `ring` the focus outline.
146
+
147
+ Surfaces are layered tiers, not shadow depths: `surfaceSunken` (app chrome such
148
+ as the Drawer rail) < `background` (content) < `card`/`popover` (raised panels)
149
+ < `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
150
+ `borderStrong` on filled surfaces. `textDim`/`mutedForeground` hold at least
151
+ 7:1 against `background`/`card` and 6:1 against `muted` in both schemes,
152
+ enforced by a package test.
153
+
154
+ When `currentTheme` is `"system"` the package tracks the OS color scheme and
155
+ updates every `useTheme()`/`useStyles()` consumer through the theme store. Do
156
+ not add app-level `Appearance` or `matchMedia` listeners for package
157
+ components. `THEME_STORAGE_KEY` (from `state`) is the persisted-preference key,
158
+ for a pre-boot theme script.
159
+
160
+ `useStyles()` memoizes theme-aware styles; its factory receives
161
+ `{ theme, spacing, withAlpha }` and its return also carries the `useTheme()`
162
+ helpers.
114
163
 
115
164
  ```tsx
116
165
  import { useStyles } from "@mrmeg/expo-ui/hooks";
@@ -124,139 +173,89 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
124
173
  }));
125
174
  ```
126
175
 
176
+ ### Web theming is CSS variables
177
+
178
+ On web every `theme.colors.*` value resolves to a CSS custom property
179
+ (`var(--c-<kebab-token>)`), so styles built from the theme — including HTML
180
+ shells baked at export time — re-theme purely in CSS when `html[data-theme]`
181
+ changes. Native keeps literal values.
182
+
183
+ Consequence: hex-suffix alpha (`theme.colors.x + "15"`) does not work. Use
184
+ `withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` as well as
185
+ from `useTheme()`.
186
+
187
+ `constants` exports the escape hatches:
188
+
189
+ | Export | Use |
190
+ |---|---|
191
+ | `getThemeCssVariables(overrides?)` | The `--c-*` (and `--c-*-rgb`) definitions per scheme for a global `<style>` in `+html.tsx`; takes per-scheme brand overrides |
192
+ | `rawThemeColors.light` / `.dark` | Literal hex/rgba maps on every platform, for sinks that cannot take `var()` (e.g. `<meta name="theme-color">`) |
193
+ | `resolveRawColor(color, scheme)` | Maps a `var(--c-*)` color back to that scheme's literal; literals pass through |
194
+
195
+ The React Navigation maps (`colors.<scheme>.navigation`) stay literal on web
196
+ too, because @react-navigation parses them with a color library.
197
+
127
198
  ### Spacing and density
128
199
 
129
- `spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md` 16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64) plus semantic density tokens that package components read from. Reach for the semantic names in app code so a future density tune lands everywhere at once:
200
+ `spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md`
201
+ 16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64), radii (`radiusNone` 0,
202
+ `radiusXs` 4, `radiusSm` 8, `radiusMd` 12, `radiusLg` 14, `radiusXl` 18,
203
+ `radius2xl` 24, `radiusFull` 9999), icon sizes (`iconXs` 12, `iconSm` 16,
204
+ `iconMd` 24, `iconLg` 32, `iconXl` 48), and the semantic density tokens package
205
+ components read from. Prefer the semantic names so a density tune lands
206
+ everywhere at once:
130
207
 
131
208
  | Token | Value | Use |
132
209
  |-------|-------|-----|
133
- | `screenPadding` | 16 | Horizontal gutter for screens, scroll content, and landing blocks |
134
- | `sectionSpacing` | 24 | Gap between grouped lists, cards, or settings groups |
135
- | `cardPadding` | 16 | `Card`, `StatCard`, and any bordered panel |
210
+ | `screenPadding` | 16 | Horizontal gutter for screens, scroll content, landing blocks |
211
+ | `sectionSpacing` | 24 | Gap between grouped lists, cards, settings groups |
212
+ | `cardPadding` | 16 | `Card`, `StatCard`, any bordered panel |
136
213
  | `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
137
214
  | `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows and hand-rolled list rows |
138
- | `rowGap` | 12 | Gap between row media, content, and actions |
215
+ | `rowGap` | 12 | Gap between row media, content, actions |
139
216
  | `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
140
217
  | `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
141
218
  | `touchTarget` | 44 | Minimum native hit area; pair a smaller visual height with `hitSlop` |
142
219
 
143
- Controls (`Button`, `TextInput`, `Select`, `Tabs`, `Toggle`) size themselves from their own `size` prop (28/32/36/40) and do not read these tokens.
220
+ Controls size themselves from their own `size` prop and ignore these tokens:
221
+ `Button` 28/32/40 (`sm`/`md`/`lg`), `TextInput` and `Select` 32/36/40
222
+ (`sm`/`md`/`lg`), `Toggle` 32/36/40 (`sm`/`default`/`lg`), `Tabs` 32/36
223
+ (`sm`/`md`).
144
224
 
145
225
  ### Color overrides
146
226
 
147
- The package ships a neutral default palette. `useTheme()` resolves colors in
148
- three layers, last wins:
149
-
150
- 1. **Package defaults** — the built-in zinc/teal palette.
151
- 2. **Global brand** (`setColors`) — your app's one palette, applied everywhere.
152
- 3. **Scoped override** (`ThemeColorScope`) — a per-subtree palette layered on
153
- top of the brand, for transient theming that should not leak globally.
154
-
155
- With no override at either layer, `useTheme()` returns the base theme by
156
- reference — identical to pre-override behavior, with no extra allocation.
157
-
158
- Forward your app's brand palette once (e.g. from a top-level effect). Each
159
- scheme is a `Partial<ThemeColors>`, so you only specify the keys you want to
160
- re-skin:
227
+ Colors resolve in three layers, last wins: package defaults (zinc/teal) →
228
+ global brand (`setColors`) → subtree scope (`ThemeColorScope`). Each override is
229
+ `{ light?, dark? }` of `Partial<ThemeColors>`, so you only pass the keys you
230
+ re-skin. With no override at either layer, `useTheme()` returns the base theme
231
+ by reference — no extra allocation.
161
232
 
162
233
  ```tsx
163
- import { useThemeStore } from "@mrmeg/expo-ui/state";
234
+ import { ThemeColorScope, useThemeStore } from "@mrmeg/expo-ui/state";
164
235
 
236
+ // One app-wide brand palette.
165
237
  useThemeStore.getState().setColors({
166
238
  light: { primary: "#7c3aed", accent: "#14b8a6" },
167
239
  dark: { primary: "#a78bfa", accent: "#2dd4bf" },
168
240
  });
169
- ```
170
-
171
- Use `ThemeColorScope` to override colors for one subtree without touching the
172
- global theme — a survey with a user-created palette, a preview pane, an embed.
173
- It is React context, so it applies only inside the provider and unwinds when
174
- that subtree unmounts. Nested scopes compose (inner keys win, outer fill in),
175
- and a scoped key wins over the global brand for components inside it:
176
-
177
- ```tsx
178
- import { ThemeColorScope } from "@mrmeg/expo-ui/state";
179
241
 
242
+ // Per-subtree palette that must not leak globally: user-created themes,
243
+ // preview panes, embeds.
180
244
  <ThemeColorScope colors={{ light: surveyColors, dark: surveyColors }}>
181
- {/* every package component in here renders with the survey palette */}
182
245
  <SurveyScreen />
183
246
  </ThemeColorScope>
184
247
  ```
185
248
 
186
- ### Font overrides
187
-
188
- The package bundles Inter (sans), Georgia (serif), and the platform's system
189
- monospace (mono). Most apps ship their own faces — forward them with
190
- `setFonts`, the font counterpart to `setColors`. Call it once at startup,
191
- after your fonts are registered:
192
-
193
- ```tsx
194
- import { useThemeStore } from "@mrmeg/expo-ui/state";
195
-
196
- useThemeStore.getState().setFonts({
197
- families: {
198
- sansSerif: {
199
- regular: "HankenGrotesk_400Regular",
200
- medium: "HankenGrotesk_500Medium",
201
- semibold: "HankenGrotesk_600SemiBold",
202
- bold: "HankenGrotesk_700Bold",
203
- },
204
- serif: {
205
- regular: "Newsreader_400Regular",
206
- semibold: "Newsreader_600SemiBold",
207
- },
208
- mono: {
209
- regular: "JetBrainsMono_400Regular",
210
- medium: "JetBrainsMono_500Medium",
211
- },
212
- },
213
- webWeightStrategy: "family",
214
- });
215
- ```
216
-
217
- Overrides are partial at both levels:
218
-
219
- - **Per group** — pass `sansSerif` alone and `serif`/`mono` keep the package
220
- defaults.
221
- - **Per weight** — within an overridden group, weights you don't provide fall
222
- back to that group's `regular` (never to a package face), so registering
223
- only Regular + Medium is enough.
224
-
225
- `setFonts({})` clears back to the defaults.
226
-
227
- When `sansSerif` is overridden, `useResources` skips downloading the packaged
228
- Inter faces entirely (native `.ttf`s and the web Google-Fonts stylesheet) —
229
- your app owns loading its own families, and nothing would reference Inter.
230
- For the skip to apply, call `setFonts` before the hook mounts (module scope,
231
- or ahead of rendering the root); a later call still re-skins all text, it
232
- just doesn't prevent the Inter download. The Feather icon font always loads.
233
-
234
- **Pick the right `webWeightStrategy`** — this is the one non-obvious part:
235
-
236
- | Strategy | Use when | Effect |
237
- |---|---|---|
238
- | `"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 |
239
- | `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
240
-
241
- If you load fonts through `expo-font` / `@expo-google-fonts`, choose
242
- `"family"`. `expo-font` registers each weight as its own single-face family on
243
- web as well as native, so emitting a numeric weight on top makes the browser
244
- synthesise a *second* layer of bold over an already-bold face.
245
-
246
- Native always behaves as `"family"` regardless of this setting.
247
-
248
- > Prefer `setFonts` over patching `node_modules`. Bun keys
249
- > `patchedDependencies` to an exact `name@version`, so a font patch silently
250
- > stops applying the next time you bump the package — with no warning.
249
+ `ThemeColorScope` is React context: it applies only inside the provider and
250
+ unwinds when that subtree unmounts. Nested scopes compose (inner keys win,
251
+ outer fill in), and a scoped key beats the global brand inside it.
251
252
 
252
253
  ### Shape overrides
253
254
 
254
- `setShape` is the geometry counterpart to `setColors`/`setFonts`, grouped per
255
- component. It currently covers Button:
255
+ `setShape` is the geometry counterpart, grouped per component. It currently
256
+ covers Button:
256
257
 
257
258
  ```tsx
258
- import { useThemeStore } from "@mrmeg/expo-ui/state";
259
-
260
259
  useThemeStore.getState().setShape({
261
260
  button: {
262
261
  borderRadius: 9999, // pill buttons everywhere; package default is 12
@@ -265,11 +264,11 @@ useThemeStore.getState().setShape({
265
264
  });
266
265
  ```
267
266
 
268
- Precedence is unchanged and caller-wins: a per-instance `style={{ borderRadius }}`
269
- or `withShadow` prop still beats the global override, which beats the package
270
- default. `setShape({})` clears back to the defaults.
267
+ Precedence is caller-wins: a per-instance `style={{ borderRadius }}` or
268
+ `withShadow` prop beats the global override, which beats the package default.
269
+ `setShape({})` clears back to the defaults.
271
270
 
272
- Use `StyledText` for theme-aware text:
271
+ ## Typography
273
272
 
274
273
  ```tsx
275
274
  import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/components";
@@ -280,22 +279,26 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
280
279
  <StyledText semantic="label" tx="common.email" />
281
280
  ```
282
281
 
283
- Useful `StyledText` props:
282
+ `StyledText` props:
284
283
 
285
- - `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`
284
+ - `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`, `eyebrow`
286
285
  - `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
287
286
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
288
- - `variant`: `sansSerif`, `serif`, `mono` (or the `MonoText` alias for code,
289
- IDs, and tabular figures)
290
- - `align`, `tx`, `txOptions`
291
- - `selectable`: defaults to `true` for readable copy; package controls disable
292
- selection for labels and other interactive chrome where accidental drag
293
- selection would feel broken.
294
-
295
- `tx` support is opt-in. Without a configured translator, `tx` renders its
296
- fallback text when provided and otherwise renders the key; package-owned
297
- defaults such as notification titles use readable fallback text. Consumers
298
- that already use i18n can connect it once near app startup:
287
+ - `variant`: `sansSerif`, `serif`, `mono`
288
+ - `align`, `text`, `tx`, `txOptions`
289
+ - `selectable`: defaults to `true`; package controls disable it for labels and
290
+ interactive chrome where accidental drag selection would feel broken.
291
+
292
+ Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
293
+ `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText` (code, IDs,
294
+ tabular figures), `SerifText`, `SansSerifText`, `SerifBoldText`,
295
+ `SansSerifBoldText`. `TextClassContext`, `TextColorContext`,
296
+ `TextStyleContext`, and `TextSelectabilityContext` are advanced exports package
297
+ controls use to pass nested text styling and control-label selectability.
298
+
299
+ `tx` is opt-in. Without a configured translator it renders its fallback text if
300
+ provided, otherwise the key; package-owned defaults such as notification titles
301
+ use readable fallbacks. Connect an existing i18n setup once at startup:
299
302
 
300
303
  ```tsx
301
304
  import { configureExpoUiI18n } from "@mrmeg/expo-ui/lib";
@@ -304,87 +307,223 @@ import { i18n } from "./i18n";
304
307
  configureExpoUiI18n((key, options) => i18n.t(key, options));
305
308
  ```
306
309
 
310
+ ### Bundled fonts
311
+
312
+ Inter is the sans face on every platform, in four static weights
313
+ (`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`)
314
+ from the bundled `@expo-google-fonts/inter`. Serif is Georgia; mono is the
315
+ platform system monospace.
316
+
317
+ `useResources()` loads those four weights plus `Feather.font` (from the
318
+ package-managed `@expo/vector-icons`) on native, so `StyledText`'s
319
+ `light`–`bold` range resolves to real files instead of a faked OS bold. On web
320
+ it injects one Google Fonts Inter stylesheet (all four weights) after hydration
321
+ unless the app already added it; web weight differentiation then comes from a
322
+ numeric `fontWeight` on the shared `"Inter"` family. For better first paint in
323
+ Expo Router web apps, add the links to app-owned `app/+html.tsx`:
324
+
325
+ ```tsx
326
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
327
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
328
+ <link
329
+ id="mrmeg-expo-ui-inter"
330
+ rel="stylesheet"
331
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
332
+ />
333
+ ```
334
+
335
+ ### Font overrides
336
+
337
+ Forward your own faces with `setFonts`, once at startup after they are
338
+ registered:
339
+
340
+ ```tsx
341
+ useThemeStore.getState().setFonts({
342
+ families: {
343
+ sansSerif: {
344
+ regular: "HankenGrotesk_400Regular",
345
+ medium: "HankenGrotesk_500Medium",
346
+ semibold: "HankenGrotesk_600SemiBold",
347
+ bold: "HankenGrotesk_700Bold",
348
+ },
349
+ serif: { regular: "Newsreader_400Regular", semibold: "Newsreader_600SemiBold" },
350
+ mono: { regular: "JetBrainsMono_400Regular", medium: "JetBrainsMono_500Medium" },
351
+ },
352
+ webWeightStrategy: "family",
353
+ });
354
+ ```
355
+
356
+ Overrides are partial at both levels: pass `sansSerif` alone and `serif`/`mono`
357
+ keep the package defaults, and within an overridden group any weight you omit
358
+ falls back to that group's `regular` (never to a package face), so Regular +
359
+ Medium is enough. `setFonts({})` clears back to the defaults.
360
+
361
+ When `sansSerif` is overridden, `useResources` skips downloading the packaged
362
+ Inter faces entirely (native `.ttf`s and the web stylesheet). For the skip to
363
+ apply, call `setFonts` before the hook mounts (module scope, or ahead of
364
+ rendering the root); a later call still re-skins all text, it just doesn't
365
+ prevent the download. Feather always loads.
366
+
367
+ | `webWeightStrategy` | Use when | Effect |
368
+ |---|---|---|
369
+ | `"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 |
370
+ | `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
371
+
372
+ `expo-font` registers each weight as its own single-face family on web as well
373
+ as native, so emitting a numeric weight on top makes the browser synthesise a
374
+ *second* bold layer over an already-bold face. Native always behaves as
375
+ `"family"`.
376
+
377
+ > Prefer `setFonts` over patching `node_modules`. Bun keys
378
+ > `patchedDependencies` to an exact `name@version`, so a font patch silently
379
+ > stops applying the next time you bump the package.
380
+
307
381
  ## Component Guide
308
382
 
309
- All components are exported from `@mrmeg/expo-ui/components`; direct imports such as `@mrmeg/expo-ui/components/Button` are supported. Use this table as the first stop before building a new primitive in a consumer app.
310
-
311
- ### LLM Component Use-Case Index
312
-
313
- | Component | Use For | Prefer It Instead Of | Common Example Use Cases |
314
- |-----------|---------|----------------------|--------------------------|
315
- | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections, dense detail pages |
316
- | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs, simple blocking messages |
317
- | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
318
- | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Hand-rolled circles with a nested `Image` and initials `Text` | Account menu, comment authors, assignee pickers, testimonial cards, overlapping team/collaborator stacks |
319
- | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
320
- | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms, contextual details |
321
- | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
322
- | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries, dashboards |
323
- | `Carousel` | Horizontally snapping slide rows with pressable dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries; `itemWidth` below 1 is a fraction of the carousel width so the next slide peeks, but that fraction measures the viewport until the first layout — pass absolute pixels (`> 1`) inside a constrained parent |
324
- | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters, notification opt-ins |
325
- | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text, optional details |
326
- | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user, blocking warnings |
327
- | `DismissKeyboard` | Tap-away keyboard dismissal | Screen-level keyboard handling | Forms, search screens, sign-in screens |
328
- | `Drawer` | Side panels and drawer navigation | Custom sliding panels | Filter drawer, app navigation drawer, inspector panel |
329
- | `DropdownMenu` | Menus and command lists | Homemade popover menus | Row actions, account menu, sort menu, checkbox/radio menu groups |
330
- | `EmptyState` | No-data or recoverable error regions | One-off empty placeholders | Empty inbox, no search results, missing permissions, failed list load |
331
- | `ErrorBoundary` | React render error fallback | Unhandled screen crashes | Route-level fallback, feature boundary, recoverable widget crashes |
332
- | `Icon` | Feather or custom icons with theme tokens | Raw vector icons with hardcoded colors | Button accessories, empty-state icons, menu icons, status glyphs |
333
- | `InputOTP` | Verification code entry | Multiple manually managed text inputs | Email codes, SMS codes, MFA, invite codes |
334
- | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers | Screen roots, composer footers, form-heavy subtrees |
335
- | `Label` | Accessible form labels | Plain styled text labels | Required labels, disabled labels, field group labels |
336
- | `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers | Web pages, tablet layouts, settings forms, auth panels |
337
- | `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, action toasts, loading toast, bottom-position alerts |
338
- | `Popover` | Anchored contextual content | Custom anchored views | Inline help, quick previews, contextual controls, small forms |
339
- | `Progress` | Determinate or indeterminate progress | Layout-shifting spinners for progress regions | Upload progress, onboarding completion, long-running task state |
340
- | `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows | Plan interval, visibility choice, survey answer, preference setting |
341
- | `Select` | Option menus | Custom dropdowns | Country picker, category selector, status selector, compact form choice; `label` drives default item text and overlay text uses popover foreground tokens |
342
- | `Separator` | Horizontal or vertical dividers | Border-only spacer views | Menu dividers, section dividers, card dividers |
343
- | `Skeleton`, `SkeletonText`, `SkeletonAvatar`, `SkeletonCard` | Loading placeholders | Blank space or generic spinners | List loading, profile card loading, dashboard placeholders |
344
- | `Slider` | Numeric value selection | Custom pan gesture track | Volume, percentage, rating, threshold, range-like settings; accent active track and thumb affordance |
345
- | `StatusBar` | Theme-aware native status bar | Per-screen status-bar duplication | Root layout status styling, dark/light mode updates |
346
- | `StyledText` and text aliases | Theme-aware typography | Raw `Text` with hardcoded styles | Titles, headings, labels, body copy, captions, translated text |
347
- | `Switch` | Binary settings | Custom toggle switches | Enable notifications, privacy setting, feature toggles; accent checked track with softened thumb border |
348
- | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | In-page tabbed views | Custom segmented/tab controls | Profile sections, report views, settings categories |
349
- | `TextInput` | Text entry | Raw `TextInput` with repeated label/error code | Email/password, search, numeric input, multiline notes |
350
- | `Toggle`, `ToggleIcon` | Pressed/unpressed control | Button with local selected styling | Favorite, mute, bold/italic, view mode button |
351
- | `ToggleGroup`, `ToggleGroupItem`, `ToggleGroupIcon` | Single or multi toggle groups | Custom segmented controls | Alignment, formatting toolbar, filter chips, view mode switcher |
352
- | `Tooltip` | Short hover/focus help | Persistent helper text or custom hover cards | Icon button labels, field hints, disabled action explanations |
353
-
354
- ### Compound Parts And Aliases
355
-
356
- Most compound components support both direct named imports and dot notation on the root component. Prefer the named exports when code completion or LLM context benefits from explicit names.
383
+ Every component is exported from `@mrmeg/expo-ui/components`, and from
384
+ `@mrmeg/expo-ui/components/<Name>` for a direct import. Check this table before
385
+ building a new primitive.
386
+
387
+ | Component | Use For | Examples |
388
+ |-----------|---------|----------|
389
+ | `Accordion` | Multi-section disclosure | FAQ lists, grouped settings, help sections |
390
+ | `Alert` | Cross-platform imperative alerts | Destructive confirms, blocking messages |
391
+ | `AnimatedView` | Entrance and visibility animation | Staggered rows, revealed panels |
392
+ | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Account menu, comment authors, overlapping team stacks |
393
+ | `Badge` | Short status labels | Draft/active state, counts, plan and role tags |
394
+ | `BottomSheet` | Mobile-first modal sheets | Action pickers, mobile filters, quick edit forms |
395
+ | `Button` | Commands and CTAs | Submit, save, delete, navigation CTAs; loading state keeps the resting width |
396
+ | `Card` | Framed content groups | List items, pricing plans, settings sections, dashboards |
397
+ | `Carousel` | Horizontally snapping slides with pressable dots | Testimonials, onboarding pages, image galleries |
398
+ | `Checkbox` | Boolean selection | Terms consent, checklists, multi-select filters |
399
+ | `Collapsible` | One-off disclosure | Advanced settings, hidden helper text |
400
+ | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Confirm delete, edit profile, blocking warnings |
401
+ | `DismissKeyboard` | Tap-away keyboard dismissal | Forms, search and sign-in screens |
402
+ | `Drawer` | Side panels and drawer navigation | Filter drawer, app navigation, inspector panel |
403
+ | `DropdownMenu` | Menus and command lists | Row actions, account menu, sort menu, checkbox/radio groups |
404
+ | `EmptyState` | No-data or recoverable error regions | Empty inbox, no results, failed list load |
405
+ | `ErrorBoundary` | React render error fallback | Route-level and feature-level fallbacks |
406
+ | `Icon` | Feather or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
407
+ | `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
408
+ | `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
409
+ | `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
410
+ | `Label` | Accessible form labels | Required, disabled, and group labels |
411
+ | `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth panels |
412
+ | `Notification` | Global toast surface | Saved/error/sync toasts, action toasts, loading toast |
413
+ | `Popover` | Anchored contextual content | Inline help, quick previews, small forms |
414
+ | `Progress` | Determinate or indeterminate progress | Upload progress, onboarding completion |
415
+ | `RadioGroup` | Mutually exclusive choices | Plan interval, visibility choice, survey answer |
416
+ | `SectionHeader` | Eyebrow / title / description section intro | Landing sections, settings groups, report headers |
417
+ | `SegmentedControl` | Native segmented picker (`@expo/ui`) | Platform-native view switchers, iOS-style filters |
418
+ | `Select` | Option menus | Country, category, status pickers; `label` drives default item text |
419
+ | `Separator` | Horizontal or vertical dividers | Menu, section, and card dividers |
420
+ | `Skeleton` | Loading placeholders | List, profile card, dashboard loading |
421
+ | `Slider` | Numeric value selection (`@expo/ui`) | Volume, percentage, rating, threshold |
422
+ | `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | KPI rows, analytics summaries, usage meters |
423
+ | `StatusBar` | Theme-aware native status bar | Root layout status styling |
424
+ | `StyledText` and aliases | Theme-aware typography | Titles, labels, body copy, captions, translated text |
425
+ | `Switch` | Binary settings | Notification, privacy, and feature toggles |
426
+ | `Tabs` | In-page tabbed views | Profile sections, report views, settings categories |
427
+ | `TextInput` | Text entry with label, helper/error text, clear button, password reveal, numeric filtering, left/right elements | Email/password, search, multiline notes |
428
+ | `Toggle` | Pressed/unpressed control | Favorite, mute, bold/italic, view mode |
429
+ | `ToggleGroup` | Single or multi toggle groups | Alignment, formatting toolbar, filter chips |
430
+ | `Tooltip` | Short hover/focus help | Icon button labels, field hints, disabled-action reasons |
431
+
432
+ ### Compound Parts
357
433
 
358
434
  | Root | Exported Parts |
359
435
  |------|----------------|
360
436
  | `Accordion` | `AccordionItem`, `AccordionTrigger`, `AccordionContent` |
361
- | `AlertDialog` | `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogAction`, `AlertDialogCancel` |
437
+ | `AlertDialog` | `AlertDialogTrigger`, `AlertDialogContent`, `AlertDialogTitle`, `AlertDialogDescription`, `AlertDialogAction`, `AlertDialogCancel`, plus `DialogHeader`/`DialogFooter` (as `AlertDialog.Header`/`.Footer`) |
438
+ | `Avatar` | `AvatarGroup`, `getAvatarInitials` |
362
439
  | `BottomSheet` | `BottomSheetTrigger`, `BottomSheetContent`, `BottomSheetHandle`, `BottomSheetHeader`, `BottomSheetBody`, `BottomSheetFooter`, `BottomSheetClose` |
440
+ | `Button` | `ButtonText`, `ButtonIcon` |
363
441
  | `Card` | `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` |
364
442
  | `Collapsible` | `CollapsibleTrigger`, `CollapsibleContent` |
365
443
  | `Dialog` | `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose` |
366
444
  | `Drawer` | `DrawerTrigger`, `DrawerContent`, `DrawerHeader`, `DrawerBody`, `DrawerFooter`, `DrawerClose`, `DrawerToggleCollapse` |
367
445
  | `DropdownMenu` | `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuPortal`, `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` |
446
+ | `Item` | `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
368
447
  | `Popover` | `PopoverTrigger`, `PopoverContent`, `PopoverHeader`, `PopoverBody`, `PopoverFooter` |
369
448
  | `RadioGroup` | `RadioGroupItem` |
370
449
  | `Select` | `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` |
371
450
  | `Skeleton` | `SkeletonText`, `SkeletonAvatar`, `SkeletonCard` |
372
- | `Tabs` | `TabsList`, `TabsTrigger`, `TabsContent` |
451
+ | `Tabs` | `TabsList`, `TabsTrigger`, `TabsContent`, `TabsTriggerText` (`Tabs.Trigger.Text`) |
373
452
  | `Toggle` | `ToggleIcon` |
374
453
  | `ToggleGroup` | `ToggleGroupItem`, `ToggleGroupIcon` |
375
454
  | `Tooltip` | `TooltipTrigger`, `TooltipContent`, `TooltipBody` |
376
455
 
377
- Text aliases are exported for common semantic typography: `SerifText`, `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`, `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`, `BodyText`, `CaptionText`, and `LabelText`. `TextClassContext`, `TextColorContext`, `TextStyleContext`, and `TextSelectabilityContext` are advanced context exports used by package controls to pass nested text styling and control-label selectability.
378
-
379
- ### Common Patterns
380
-
381
- Use `Button.preset`, not `variant`. `default` is the neutral primary action, `secondary` is a neutral secondary surface, `outline` is for lower-emphasis actions, `ghost` is for compact toolbars, `link` is for text-like commands, and `destructive` is for dangerous actions. Button visible heights are compact: `sm` 28px, `md` 32px, and `lg` 40px. Native Button targets preserve tap comfort with computed hit slop up to 44px. Nested `StyledText` children inherit the selected Button size, so use `size="sm"` for compact popover, tooltip, and toolbar triggers.
382
-
383
- Use `StyledText` or its aliases instead of raw `Text` whenever the text is part of app UI. Use `TextInput` for labeled fields because it already owns label, helper text, error text, clear buttons, password visibility, numeric filtering, and left/right elements.
384
-
385
- `Drawer.Header` accepts `icon`, `title`, and `action` slots for a compact app-brand row. Pass a string title to use the package typography, and place `Drawer.ToggleCollapse` in `action` for a trailing rail control:
386
-
456
+ Every part above has a named export. Dot notation on the root exists only for
457
+ `AlertDialog`, `BottomSheet`, `Button`, `Dialog`, `Drawer`, `Popover`,
458
+ `RadioGroup`, `Select`, `Tabs`, and `Tooltip`; `Accordion`, `Avatar`, `Card`,
459
+ `Collapsible`, `DropdownMenu`, `Item`, `Skeleton`, `Toggle`, and `ToggleGroup`
460
+ are named exports only.
461
+
462
+ ### Hooks
463
+
464
+ | Hook | Returns |
465
+ |------|---------|
466
+ | `useResources()` | `{ loaded, error }` — loads Feather plus the Inter faces |
467
+ | `useTheme()` | Active theme, scheme helpers, shadow/contrast/alpha helpers |
468
+ | `useStyles(factory)` | Memoized theme-aware styles plus the `useTheme()` helpers |
469
+ | `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
470
+ | `useFontStyle(weight?, variant?)` | `{ fontFamily, fontWeight? }` resolved through `setFonts` overrides |
471
+ | `useReducedMotion()` | `true` when the OS asks for reduced motion |
472
+ | `useScalePress(options?)` | Animated style plus `onPressIn`/`onPressOut` for a `Pressable` (`scaleTo`, `haptic`, `disabled`) |
473
+ | `useStaggeredEntrance(options?)` | Entrance animated style for list rows (`delay`; `STAGGER_DELAY` is 30) |
474
+
475
+ `SsrViewportContext` (from `state`) supplies the first-render viewport width on
476
+ web, where the window cannot be read during export or hydration.
477
+
478
+ ### Patterns And Gotchas
479
+
480
+ - Use `Button.preset`, not `variant`: `default` is the neutral primary action,
481
+ `secondary` a neutral secondary surface, `outline` lower emphasis, `ghost`
482
+ compact toolbars, `link` text-like commands, `destructive` dangerous actions.
483
+ Native targets add computed hit slop up to 44px. Nested `StyledText` inherits
484
+ the Button size, so use `size="sm"` for popover, tooltip, and toolbar
485
+ triggers.
486
+ - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID`
487
+ is the label's own id, `htmlFor` is the input's id (`<Label
488
+ nativeID="email-label" htmlFor="email-input">` + `<TextInput
489
+ nativeID="email-input" />`). One id on both renders duplicate ids on web and
490
+ associates nothing. Prefer `TextInput`'s own `label` prop when no separate
491
+ label element is needed.
492
+ - `BottomSheet` renders the platform's native sheet through `@expo/ui`: iOS
493
+ SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`. The
494
+ platform owns gestures and keyboard avoidance, so `swipeEnabled`,
495
+ `avoidKeyboard`, and `dismissKeyboardOnDrag` are accepted for call-site
496
+ ergonomics but have no effect; `BottomSheet.Content` does mount its own
497
+ tap-away keyboard-dismiss overlay while a field is focused. Android has only
498
+ two snap states (partial / expanded) and maps extra snap points to the
499
+ nearest. `BottomSheet.Content` also takes `backgroundStyle`, merged over the
500
+ themed card default on the native sheet surface (web panel, Android
501
+ `containerColor`, iOS `presentationBackground`) — pass
502
+ `{ backgroundColor: "transparent" }`, plus a `style` clearing the content
503
+ column's card fill, to let custom chrome such as a glass backdrop show
504
+ through.
505
+ - `Carousel` renders every child (no virtualization), so slides survive into
506
+ the exported HTML shell and the first client frame; use `FlatList` for large
507
+ or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
508
+ the carousel width so the next slide peeks, but that fraction measures the
509
+ viewport until first layout — pass absolute pixels (`> 1`) inside a
510
+ constrained parent.
511
+ - Pass `Avatar` both a `source` and a `name` whenever you have them: `name`
512
+ supplies the initials shown while the image is missing, loading, or failed,
513
+ plus the default accessibility label. `SkeletonAvatar` is the loading
514
+ placeholder, not a substitute. Inside `AvatarGroup`, children inherit the
515
+ group's `size`/`shape` and gain the ring, so set those on the group; the
516
+ group announces its count as a hidden summary and leaves each member
517
+ individually announceable, so do not wrap it in your own `accessible`
518
+ container.
519
+ - `Drawer.Header` takes `icon`, `title`, and `action` slots for a compact
520
+ app-brand row; a string `title` uses the package typography, and
521
+ `Drawer.ToggleCollapse` in `action` gives a trailing rail control.
522
+ `Drawer.Content` owns safe-area top/bottom padding — do not duplicate it in
523
+ children.
387
524
  ```tsx
525
+ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
526
+
388
527
  <Drawer.Header
389
528
  icon={<Icon name="hexagon" color="accent" />}
390
529
  title="Acme"
@@ -396,18 +535,8 @@ Use `StyledText` or its aliases instead of raw `Text` whenever the text is part
396
535
  />
397
536
  ```
398
537
 
399
- 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`.
400
-
401
- 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.
402
-
403
- Use `Toggle` for one pressed state, `ToggleGroup` for a related set of pressed states, `Switch` for binary settings, `RadioGroup` for small mutually exclusive choices, and `Select` for longer option sets.
404
-
405
- Pass `Avatar` both a `source` and a `name` whenever you have them: `name` supplies the initials shown while the image is missing, still loading, or after it fails, and the default accessibility label. `SkeletonAvatar` is the loading placeholder, not a substitute. Inside `AvatarGroup`, child avatars inherit the group's `size` and `shape` and gain the ring, so set those on the group rather than per child. `AvatarGroup` announces its count as a hidden summary and leaves each member individually announceable, so don't wrap it in an `accessible` container of your own.
406
-
407
538
  ### Quick Examples
408
539
 
409
- Card, badge, and CTA:
410
-
411
540
  ```tsx
412
541
  import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@mrmeg/expo-ui/components";
413
542
 
@@ -424,10 +553,8 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@mrmeg/
424
553
  </Card>
425
554
  ```
426
555
 
427
- Form controls:
428
-
429
556
  ```tsx
430
- import { Button, TextInput, Switch } from "@mrmeg/expo-ui/components";
557
+ import { Button, Switch, TextInput } from "@mrmeg/expo-ui/components";
431
558
 
432
559
  <TextInput
433
560
  label="Email"
@@ -444,8 +571,6 @@ import { Button, TextInput, Switch } from "@mrmeg/expo-ui/components";
444
571
  </Button>
445
572
  ```
446
573
 
447
- Feedback:
448
-
449
574
  ```tsx
450
575
  import { EmptyState, Progress, SkeletonCard } from "@mrmeg/expo-ui/components";
451
576
  import { notify } from "@mrmeg/expo-ui/state";
@@ -456,121 +581,66 @@ import { notify } from "@mrmeg/expo-ui/state";
456
581
 
457
582
  notify.success("Saved", {
458
583
  messages: ["Your changes were saved."],
459
- action: {
460
- label: "View",
461
- onPress: openSavedItem,
462
- },
584
+ action: { label: "View", onPress: openSavedItem },
463
585
  });
464
586
  ```
465
587
 
466
- LLM rules:
588
+ `notify` also exposes `error`, `warning`, `info`, `loading`, `hide`, and
589
+ `promise(promise, { loading, success, error })`. Notifications auto-dismiss
590
+ after 4s (`DEFAULT_NOTIFICATION_DURATION`) unless a `duration` is given;
591
+ `duration: 0` keeps one up until dismissed and `notify.loading` never
592
+ auto-dismisses. `position` is `"top"` (default) or `"bottom"`. `globalUIStore`
593
+ stays available for reactive selectors and tests.
467
594
 
468
- - Prefer `StyledText` semantic variants over raw `Text`.
469
- - Use `useTheme()` and semantic tokens instead of hardcoded colors.
470
- - Use `Button.preset`, not `variant`, for buttons.
471
- - Mount `UIProvider` before using overlays, menus, select content, popovers, tooltips, or package notifications.
472
- - Import from package exports, not `packages/ui/src/*`.
473
-
474
- ## App Startup
475
-
476
- Call `useResources()` once near the Expo app root before hiding the splash screen:
477
-
478
- ```tsx
479
- import { ThemeProvider } from "expo-router";
480
- import { colors } from "@mrmeg/expo-ui/constants";
481
- import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
482
- import { UIProvider } from "@mrmeg/expo-ui/components";
483
-
484
- export default function RootLayout() {
485
- const { scheme } = useTheme();
486
- const { loaded } = useResources();
487
-
488
- if (!loaded) return null;
489
-
490
- return (
491
- <ThemeProvider
492
- value={{
493
- dark: colors[scheme ?? "light"].dark,
494
- colors: colors[scheme ?? "light"].navigation,
495
- fonts: colors[scheme ?? "light"].fonts,
496
- }}
497
- >
498
- <UIProvider>
499
- {/* App navigation goes here. */}
500
- </UIProvider>
501
- </ThemeProvider>
502
- );
503
- }
504
- ```
505
-
506
- ## Fonts
507
-
508
- Typography is Inter across every platform, in four static weights (`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`) from `@expo-google-fonts/inter` — a package dependency, so consumers get it automatically.
509
-
510
- On native, `useResources()` loads the four static weights so `StyledText`'s `light`–`bold` range resolves to real font files instead of a faked OS bold. On web, `useResources()` injects a single Google Fonts Inter stylesheet (covering all four weights) after hydration if the app has not already added it; weight differentiation on web comes from a numeric `fontWeight` alongside the shared `"Inter"` CSS family. For better first paint in Expo Router web apps, add the links in app-owned `app/+html.tsx`:
511
-
512
- ```tsx
513
- <link rel="preconnect" href="https://fonts.googleapis.com" />
514
- <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
515
- <link
516
- id="mrmeg-expo-ui-inter"
517
- rel="stylesheet"
518
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
519
- />
520
- ```
521
-
522
- `useResources()` also loads `Feather.font` from the package-managed `@expo/vector-icons` dependency for icon rendering.
523
-
524
- ## Package Checks
525
-
526
- For a one-command release from the repo root, use:
595
+ ## Package Release
527
596
 
528
597
  ```sh
529
598
  bun run ui:release -- --patch --publish
530
599
  ```
531
600
 
532
- Replace `--patch` with `--minor`, `--major`, or an exact version such as `0.2.0`.
533
- The command updates `packages/ui/package.json` and `bun.lock`, runs all package
534
- gates, then publishes with `npm publish --access public` only when `--publish`
535
- is present. Without `--publish`, it performs the same version bump and gates as
536
- a dry run.
601
+ Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0`. The
602
+ command updates `packages/ui/package.json` and `bun.lock`, runs
603
+ `bun run packages:peer-check` then the `ui:typecheck`, `ui:test`, `ui:build`,
604
+ `ui:pack`, and `ui:consumer-smoke` gates, and publishes with
605
+ `npm publish --access public` only when `--publish` is present (which also
606
+ requires a working `npm whoami`). Without `--publish` it is the same bump and
607
+ gate run as a dry run.
608
+
609
+ A clean working tree is required; commit first or pass `--allow-dirty`.
537
610
 
538
- The release command requires a clean working tree by default. Commit current
539
- changes first, or pass `--allow-dirty` when you intentionally want to release
540
- from uncommitted local changes.
611
+ CI also installs and exports packed consumers against Expo 56 and 57
612
+ (`.github/workflows/package-compatibility.yml`).
541
613
 
542
- Release validation includes `bun run packages:peer-check`; CI also installs and
543
- exports packed consumers against Expo 56 and 57.
614
+ ### GitHub Publishing
544
615
 
545
616
  If npm login email is unavailable, publish through GitHub Actions trusted
546
- publishing instead:
617
+ publishing:
547
618
 
548
619
  1. In npm package settings for `@mrmeg/expo-ui`, add a trusted publisher:
549
- GitHub Actions, owner `mrmeg`, repository `expo-template`, workflow
550
- filename `publish-ui.yml`.
620
+ GitHub Actions, owner `mrmeg`, repository `expo-template`, workflow filename
621
+ `publish-ui.yml`.
551
622
  2. Bump `packages/ui/package.json` in a commit and push it to `main`.
552
623
 
553
- The workflow uses npm OIDC, not a checked-in token or local npm login. On push,
554
- it reads the committed package version, skips cleanly if that version is already
555
- published, otherwise runs the package gates and publishes from `packages/ui`.
556
- The workflow can also be run manually with `version=patch` and `ref=main`; manual
557
- runs bump the package version, update `bun.lock`, run the package gates, commit
558
- the version bump, and publish.
624
+ `publish-ui.yml` runs on a push to `main` touching `packages/ui/package.json`
625
+ and on `workflow_dispatch`, using npm OIDC rather than a checked-in token or
626
+ local npm login. On push it reads the committed version, skips cleanly if that
627
+ version is already published, otherwise runs the gates and publishes from
628
+ `packages/ui`. Manual runs take `version` (`patch`, `minor`, `major`, or exact
629
+ `x.y.z`) and `ref` (default `main`); they bump the version, update `bun.lock`,
630
+ run the gates, publish, then commit and push the bump. If a manual run fails
631
+ after the bump landed, rerun it with the exact current version (e.g.
632
+ `version=0.1.3`) — exact versions do not bump again.
559
633
 
560
634
  Keep `repository.url` in `package.json` as
561
- `git+https://github.com/mrmeg/expo-template.git`. npm trusted publishing checks
562
- that metadata during publish, and publish-time URL normalization can block OIDC
563
- auth.
635
+ `git+https://github.com/mrmeg/expo-template.git`: npm trusted publishing checks
636
+ that metadata, and publish-time URL normalization can block OIDC auth.
564
637
 
565
- If npm trusted publishing is blocked by package settings, add an npm automation
566
- or granular publish token to GitHub Actions secrets as `NPM_TOKEN` and rerun the
567
- same workflow. The token is used only for the publish step.
638
+ If package settings block trusted publishing, add an npm automation or granular
639
+ publish token to GitHub Actions secrets as `NPM_TOKEN` and rerun the same
640
+ workflow; it is used only for the publish step. Keep npm tokens in developer or
641
+ CI configuration, never in the repository.
568
642
 
569
- If the manual workflow fails after the version is already bumped, rerun it with
570
- the exact current package version, for example `version=0.1.3`. Exact-version
571
- reruns do not bump again.
572
-
573
- Manual package checks:
643
+ ## Package Checks
574
644
 
575
645
  ```sh
576
646
  bun run packages:peer-check
@@ -581,9 +651,8 @@ bun run ui:pack
581
651
  bun run ui:consumer-smoke
582
652
  ```
583
653
 
584
- `bun run ui:pack` runs a dry pack so the published file list and package size can be inspected before release.
585
- `bun run ui:consumer-smoke` installs the packed tarball into a clean fixture,
586
- checks the documented export-map files, type-checks all public package
587
- entrypoints, verifies that `@mrmeg/expo-ui/constants` can be imported by
588
- plain Node ESM for token inspection, and runs an Expo SDK 56 iOS export
589
- against the packed package without a custom Metro config.
654
+ `ui:pack` is a dry pack, so the published file list and package size can be
655
+ inspected before release. `ui:consumer-smoke` installs the packed tarball into
656
+ a clean fixture, checks every documented export-map target resolves,
657
+ type-checks all public entrypoints, and runs an iOS `expo export` against the
658
+ packed package at the workspace's Expo version, without a custom Metro config.