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