@mrmeg/expo-ui 0.27.1 → 0.28.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 (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +87 -16
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +5 -2
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +73 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +20 -30
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +71 -26
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
package/README.md CHANGED
@@ -59,6 +59,14 @@ Importable paths: root, `components`, `components/*`, `constants`,
59
59
  `constants/*`, `hooks`, `hooks/*`, `state`, `state/*`, `lib`. Never import from
60
60
  `dist/*` or a source checkout path.
61
61
 
62
+ The package declares `sideEffects`: only `state/themeStore` does work at import
63
+ time (on native it loads the saved theme and starts the OS color-scheme
64
+ listener). A bundler that tree-shakes (Expo with `EXPO_UNSTABLE_TREE_SHAKING=1`,
65
+ esbuild, Rollup, webpack) keeps only the modules a barrel import actually uses,
66
+ so `import { Button } from "@mrmeg/expo-ui"` costs about what the
67
+ `components/Button` deep import does. Metro without tree shaking bundles
68
+ everything a file imports, so the deep imports stay the smaller choice there.
69
+
62
70
  ## App Startup
63
71
 
64
72
  Call `useResources()` once near the Expo app root before hiding the splash
@@ -97,6 +105,21 @@ export default function RootLayout() {
97
105
  }
98
106
  ```
99
107
 
108
+ On web with server rendering, tell the kit the visitor's scheme for the first
109
+ render: `<InitialSchemeProvider scheme={cookieScheme}>` around the root layout
110
+ (or `<UIProvider initialScheme>` when nothing above it reads the theme), where
111
+ `cookieScheme` is `"light"` / `"dark"` read from the same cookie on the server
112
+ and the client — see the template's `shared/ssrColorScheme.ts`. `useTheme()`
113
+ paints it until the persisted preference has loaded.
114
+
115
+ A Radix-backed component (`Tabs`, `Accordion`, `Collapsible`, `Select`,
116
+ `DropdownMenu`, `Popover`) inside a server-rendered route logs a `useId`
117
+ hydration mismatch in development, because Expo Router's server renders the app
118
+ inside `+html.tsx` while the client hydrates `#root`. Wrap that subtree in
119
+ `<Hydrated fallback={…}>` (from `@mrmeg/expo-ui/components`): the fallback holds
120
+ the layout in the server HTML and the children mount in the first client render.
121
+ Keep content that must be server-rendered outside the gate.
122
+
100
123
  `UIProvider` is required before `Dialog`, `AlertDialog`, `BottomSheet`,
101
124
  `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, and package
102
125
  notifications.
@@ -113,6 +136,45 @@ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
113
136
  directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
114
137
  `keyboardVerticalOffset`).
115
138
 
139
+ ### Web setup
140
+
141
+ Web needs two more pieces, because every web theme color is a CSS variable
142
+ (see [Web theming is CSS variables](#web-theming-is-css-variables)) and the
143
+ theme store boots at `"system"`/light so the first client render matches the
144
+ server-rendered or exported HTML:
145
+
146
+ 1. **`app/+html.tsx`: define the variables.** Put `getThemeCssVariables()` (from
147
+ `constants`) in the document's global `<style>`, passing the same overrides
148
+ you give `setColors` if you re-brand. Without it every `var(--c-*)` color is
149
+ unset. A blocking inline script that stamps `data-theme` on `<html>` from
150
+ the persisted preference (`THEME_STORAGE_KEY`, from `state`) or
151
+ `prefers-color-scheme` makes the first frame paint in the visitor's scheme
152
+ before any JS runs.
153
+ 2. **Root layout: sync the store after the first commit.** Call
154
+ `syncThemeFromEnvironment()` (from `state`) in a top-level `useEffect` and
155
+ return its result as the cleanup. It reads the persisted preference and
156
+ starts following the OS color scheme. Never call it during render or at
157
+ module scope: the server has no `window`, and reading it while hydrating
158
+ would disagree with the markup. It is safe to call more than once
159
+ (StrictMode's double effects, several roots): the calls share one OS
160
+ listener, and each cleanup releases only its own call. `UIProvider` does not
161
+ call it. A web app that skips it stays on the boot default until the user
162
+ picks a theme. Native loads the preference and follows the OS at startup, so
163
+ the call is optional there.
164
+
165
+ ```tsx
166
+ // app/+html.tsx (head)
167
+ import { getThemeCssVariables } from "@mrmeg/expo-ui/constants";
168
+
169
+ <style>{`${getThemeCssVariables()} body { background-color: var(--c-background); }`}</style>
170
+
171
+ // Root layout
172
+ import { useEffect } from "react";
173
+ import { syncThemeFromEnvironment } from "@mrmeg/expo-ui/state";
174
+
175
+ useEffect(() => syncThemeFromEnvironment(), []);
176
+ ```
177
+
116
178
  ### Keyboard dismissal
117
179
 
118
180
  `DismissKeyboard` owns tap-away dismissal without claiming touches. Its scroll
@@ -135,34 +197,35 @@ import { StyledText } from "@mrmeg/expo-ui/components";
135
197
  import { spacing } from "@mrmeg/expo-ui/constants";
136
198
  import { useTheme } from "@mrmeg/expo-ui/hooks";
137
199
 
138
- export function Panel() {
139
- const { theme, getShadowStyle } = useTheme();
200
+ export function Intro() {
201
+ const { theme } = useTheme();
140
202
 
141
203
  return (
142
- <View
143
- style={[
144
- { backgroundColor: theme.colors.card, borderColor: theme.colors.border },
145
- { borderWidth: StyleSheet.hairlineWidth, borderRadius: spacing.radiusLg },
146
- { padding: spacing.cardPadding, gap: spacing.sm },
147
- getShadowStyle("subtle"),
148
- ]}
149
- >
150
- <StyledText semantic="heading">Theme-aware panel</StyledText>
204
+ <View style={{ paddingHorizontal: spacing.screenPadding, gap: spacing.sm }}>
205
+ <StyledText semantic="heading">Theme-aware section</StyledText>
151
206
  <StyledText semantic="body" style={{ color: theme.colors.mutedForeground }}>
152
207
  Uses package tokens instead of hardcoded colors.
153
208
  </StyledText>
209
+ <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border }} />
154
210
  </View>
155
211
  );
156
212
  }
157
213
  ```
158
214
 
215
+ Sections sit on the screen's 16 pt gutter and end in a hairline, not in a
216
+ bordered, shadowed panel; see [Screen layout](#screen-layout).
217
+
159
218
  `useTheme()` returns the active `theme`, resolved `scheme`, persisted
160
219
  `currentTheme`, `setTheme`, `toggleTheme`, `getShadowStyle`,
161
220
  `getFocusRingStyle(offset = 2)`, `getContrastingColor`, `getContrastRatio`,
162
221
  `getTextColorForBackground`, and `withAlpha`. `getShadowStyle(type)` takes
163
222
  `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
164
223
  `cardHover`, or `cardSubtle` and returns a cross-platform `boxShadow` (RN 0.85
165
- and react-native-web 0.21 deprecate the legacy `shadow*` props).
224
+ and react-native-web 0.21 deprecate the legacy `shadow*` props). The layers
225
+ follow the scheme: in dark the raised presets add a 1px inset white top
226
+ highlight to black shadows at real alpha (a faint drop shadow is invisible on a
227
+ near-black base); in light it is the shadow alone, at about twice the original
228
+ alpha.
166
229
 
167
230
  Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
168
231
  `card`, `cardForeground`, `popover`, `popoverForeground`, `text`, `textDim`,
@@ -172,12 +235,13 @@ Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
172
235
  `secondary` a neutral secondary surface, `accent` the teal highlight, `input`
173
236
  the default form-control border, `ring` the focus outline.
174
237
 
175
- Surfaces are layered tiers, not shadow depths: `surfaceSunken` (app chrome such
176
- as the Drawer rail) < `background` (content) < `card`/`popover` (raised panels)
177
- < `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
238
+ Surfaces are layered tiers, with an edge highlight in dark: `surfaceSunken` (app
239
+ chrome such as the Drawer rail) < `background` (content) < `card`/`popover`
240
+ (raised panels) < `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
178
241
  `borderStrong` on filled surfaces. `textDim`/`mutedForeground` hold at least
179
242
  7:1 against `background`/`card` and 6:1 against `muted` in both schemes,
180
- enforced by a package test.
243
+ enforced by a package test. If you override `background`, `card`, or `popover`,
244
+ re-check them against the template's Surface Lab (`/surface-lab`).
181
245
 
182
246
  When `currentTheme` is `"system"` the package tracks the OS color scheme and
183
247
  updates every `useTheme()`/`useStyles()` consumer through the theme store. Do
@@ -193,10 +257,13 @@ helpers.
193
257
  import { useStyles } from "@mrmeg/expo-ui/hooks";
194
258
 
195
259
  const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
196
- card: {
197
- backgroundColor: withAlpha(theme.colors.primary, 0.08),
260
+ content: {
261
+ paddingVertical: spacing.md,
262
+ gap: spacing.sectionSpacing,
263
+ },
264
+ selectedIcon: {
265
+ backgroundColor: withAlpha(theme.colors.primary, 0.12),
198
266
  borderRadius: spacing.radiusMd,
199
- padding: spacing.md,
200
267
  },
201
268
  }));
202
269
  ```
@@ -206,7 +273,13 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
206
273
  On web every `theme.colors.*` value resolves to a CSS custom property
207
274
  (`var(--c-<kebab-token>)`), so styles built from the theme — including HTML
208
275
  shells baked at export time — re-theme purely in CSS when `html[data-theme]`
209
- changes. Native keeps literal values.
276
+ changes. Native keeps literal values. Because the references are the same in
277
+ both schemes, web shares one object between them (`colors.light.colors ===
278
+ colors.dark.colors`), so a scheme switch keeps `theme.colors` identical and
279
+ anything memoized on it keeps its result. Re-brand through `setColors`, not by
280
+ mutating that object. The package keeps `html[data-theme]` on the resolved
281
+ scheme with one store subscription, started by the first `useTheme()` consumer
282
+ to mount.
210
283
 
211
284
  Consequence: hex-suffix alpha (`theme.colors.x + "15"`) does not work. Use
212
285
  `withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` as well as
@@ -235,21 +308,63 @@ everywhere at once:
235
308
 
236
309
  | Token | Value | Use |
237
310
  |-------|-------|-----|
238
- | `screenPadding` | 16 | Horizontal gutter for screens, scroll content, landing blocks |
239
- | `sectionSpacing` | 24 | Gap between grouped lists, cards, settings groups |
240
- | `cardPadding` | 16 | `Card`, `StatCard`, any bordered panel |
311
+ | `screenPadding` | 16 | The one horizontal inset per screen: scroll content, landing blocks, text and forms between row groups |
312
+ | `sectionSpacing` | 24 | Gap between screen sections and `ItemGroup`s |
313
+ | `cardPadding` | 16 | Inside `Card` and `StatCard` tiles; not a layout inset |
241
314
  | `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
242
- | `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows and hand-rolled list rows |
315
+ | `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows; `rowPaddingX` is the row's copy of the screen inset |
243
316
  | `rowGap` | 12 | Gap between row media, content, actions |
244
317
  | `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
245
318
  | `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
246
- | `touchTarget` | 44 | Minimum native hit area; pair a smaller visual height with `hitSlop` |
319
+ | `touchTarget` | 44 | Minimum hit area for a control. `Button`, `Toggle` and `ToggleGroup` items reach it on every platform: native through computed `hitSlop`, web through a transparent hit extender inside the drawn box (react-native-web ignores `hitSlop`) |
320
+ | `minTarget` | 24 | Smallest pointer target the kit draws on web (WCAG 2.5.8); `Checkbox`/`RadioGroup` grow their hit box to it around a smaller drawn box |
247
321
 
248
322
  Controls size themselves from their own `size` prop and ignore these tokens:
249
323
  `Button` 28/32/40 (`sm`/`md`/`lg`), `TextInput` and `Select` 32/36/40
250
324
  (`sm`/`md`/`lg`), `Toggle` 32/36/40 (`sm`/`default`/`lg`), `Tabs` 32/36
251
325
  (`sm`/`md`).
252
326
 
327
+ ### Screen layout
328
+
329
+ Phones are narrow, so every nested inset comes out of the content. Screens
330
+ built from this package are flat:
331
+
332
+ - **Start from `Screen`.** `<Screen edges={["bottom"]} scroll>` is the page
333
+ container: safe-area insets on the edges the screen owns (`edges` is
334
+ required — `["bottom"]` under a Stack header, `["top"]` above a tab bar,
335
+ `["top", "bottom"]` headerless, `[]` when the navigator owns both), the one
336
+ horizontal inset, the theme background under all of it. `scroll` puts the
337
+ insets on the `ScrollView`'s content so it scrolls under the island and home
338
+ indicator; `padded={false}` for full-bleed `ItemGroup` rows. Its
339
+ `contentContainerStyle` padding is added to the insets, never replaced.
340
+ Insets come from `useWindowInsets()` (`@mrmeg/expo-ui/hooks`), which also
341
+ covers content the system presents outside the app's `SafeAreaProvider`
342
+ (modals, native sheets), as `Notification`, `Dialog` and `BottomSheet` do.
343
+ - **One horizontal inset: `spacing.screenPadding` (16).** Either a container
344
+ pads or its children do, never both. `Item` rows carry the inset themselves,
345
+ so a screen of `ItemGroup`s pads nothing horizontally; text, forms, and
346
+ buttons between the groups pad their own wrapper.
347
+ - **No boxes as layout.** Don't wrap a section, a form, or a group of rows in
348
+ a bordered, shadowed, or tinted rounded panel. Separate sections with a
349
+ header, `spacing.sectionSpacing`, or a hairline (`Separator`).
350
+ - **Lists and settings are `ItemGroup` + `Item`:** plain full-width rows,
351
+ inset hairlines, no card per row, no border around the group. A `Switch`,
352
+ `Checkbox` or `Toggle` in `ItemActions` takes its accessible name from the
353
+ row's `ItemTitle` (`useItemControlLabel` does the same for a custom control).
354
+ - **Forms:** fields span the column, grouped under section headers.
355
+ - **Primary content fills the width.** Photos, video, maps, QR codes, and
356
+ charts size to the column (`width: "100%"` plus `aspectRatio`), never a
357
+ fixed size in whitespace.
358
+ - **Wide screens cap the column** instead of boxing it:
359
+ `contentContainerStyle={{ width: "100%", maxWidth: 640, alignSelf: "center" }}`.
360
+ - **`Card` is for** one item in a collection (a feed entry, a grid tile, a
361
+ carousel slide) or a single tappable object. Never nest Cards.
362
+
363
+ On a 390 pt phone a boxed settings group puts row content 33 pt from each edge
364
+ (16 screen + 1 border + 16 row) and leaves 324 pt for it; the flat group puts
365
+ it 16 pt in and leaves 358 pt. [`LLM_USAGE.md`](LLM_USAGE.md#screen-layout)
366
+ has a complete screen.
367
+
253
368
  ### Color overrides
254
369
 
255
370
  Colors resolve in three layers, last wins: package defaults (zinc/teal) →
@@ -278,23 +393,113 @@ useThemeStore.getState().setColors({
278
393
  unwinds when that subtree unmounts. Nested scopes compose (inner keys win,
279
394
  outer fill in), and a scoped key beats the global brand inside it.
280
395
 
396
+ #### Extending the palette
397
+
398
+ An app with tokens the package does not have (a brand gold, a chart series)
399
+ declares them once on `ThemeColorExtensions`, and they become part of
400
+ `ThemeColors` everywhere: `setColors` and `ThemeColorScope` accept them,
401
+ `useTheme().theme.colors.brandGold` is typed, and `getThemeCssVariables`
402
+ emits a `--c-brand-gold` variable when the overrides you pass it name the
403
+ key. No side palette, no cast.
404
+
405
+ ```ts
406
+ // theme.d.ts (any file in the app's TypeScript program)
407
+ declare module "@mrmeg/expo-ui/constants" {
408
+ interface ThemeColorExtensions {
409
+ brandGold: string;
410
+ }
411
+ }
412
+
413
+ // startup
414
+ useThemeStore.getState().setColors({
415
+ light: { primary: "#7c3aed", brandGold: "#c9a227" },
416
+ dark: { primary: "#a78bfa", brandGold: "#ffe066" },
417
+ });
418
+
419
+ // anywhere
420
+ const { theme } = useTheme();
421
+ theme.colors.brandGold; // string
422
+ ```
423
+
424
+ Two things to know. The package ships no value for an extension key, so
425
+ provide it in both schemes or it is `undefined` at runtime in the scheme that
426
+ lacks it. And on web the built-in tokens are `var(--c-*)` references while
427
+ extension values are literals per scheme, so with any override `theme.colors`
428
+ is a new object when the scheme changes (the identity caveat every override
429
+ already has); the CSS variable from `getThemeCssVariables` is the way to use
430
+ an extension token in an HTML shell.
431
+
281
432
  ### Shape overrides
282
433
 
283
- `setShape` is the geometry counterpart, grouped per component. It currently
284
- covers Button:
434
+ `setShape` is the geometry counterpart, grouped per slot. Each slot takes a
435
+ `borderRadius`; `button` also takes `withShadow`:
285
436
 
286
437
  ```tsx
287
438
  useThemeStore.getState().setShape({
288
- button: {
289
- borderRadius: 9999, // pill buttons everywhere; package default is 12
290
- withShadow: false, // flatten the `default` preset; package default is true
291
- },
439
+ button: { borderRadius: 9999, withShadow: false }, // pills, flat `default` preset
440
+ input: { borderRadius: spacing.radiusXl }, // TextInput, Select, InputOTP
441
+ card: { borderRadius: spacing.radius2xl }, // Card, StatCard, EmptyState, SkeletonCard
442
+ sheet: { borderRadius: spacing.radius2xl }, // BottomSheet top corners
443
+ badge: { borderRadius: spacing.radiusSm }, // squarer badges
444
+ dialog: { borderRadius: spacing.radius2xl }, // Dialog and AlertDialog
292
445
  });
293
446
  ```
294
447
 
448
+ | Slot | Reaches | Package default |
449
+ |---|---|---|
450
+ | `button` | Button, every preset (`withShadow` only affects `default`) | `spacing.radiusMd` (10), shadow on |
451
+ | `input` | TextInput (`outline` and `filled`; `underlined` stays square), Select trigger, InputOTP cells | `spacing.radiusMd` (10) |
452
+ | `card` | Card (surface and pressable focus ring), StatCard, EmptyState with `bordered`, SkeletonCard | `spacing.radiusLg` (14) |
453
+ | `sheet` | BottomSheet top corners, where the platform lets the sheet draw them (web, Android); iOS system sheets keep the system radius | platform |
454
+ | `badge` | Badge | `spacing.radiusFull` (pill) |
455
+ | `dialog` | Dialog and AlertDialog content | `spacing.radiusLg` (14) |
456
+
295
457
  Precedence is caller-wins: a per-instance `style={{ borderRadius }}` or
296
458
  `withShadow` prop beats the global override, which beats the package default.
297
- `setShape({})` clears back to the defaults.
459
+ `setShape({})` clears back to the defaults. A component reads its slot with
460
+ `useShape(slot)` from `@mrmeg/expo-ui/hooks` and layers the radius after its
461
+ static one; `shapeRadius(override)` turns the slot into a `{ borderRadius }`
462
+ style or `undefined` for that layering.
463
+
464
+ ### Press feedback and haptics
465
+
466
+ Every pressable shares one pressed look: a 0.97 scale (0.92 on small controls)
467
+ plus `interaction.pressedOpacity` (0.85) on filled surfaces, and
468
+ `interaction.disabledOpacity` (0.5) when disabled — both from
469
+ `@mrmeg/expo-ui/constants`. Under the OS reduce-motion setting the scale stays
470
+ at 1 and only the opacity signals the press.
471
+
472
+ Haptics are one provider-level setting, read at event time:
473
+
474
+ ```tsx
475
+ <UIProvider haptics="all">…</UIProvider>
476
+ // or, from startup code:
477
+ import { setHaptics } from "@mrmeg/expo-ui/state";
478
+ setHaptics("all");
479
+ ```
480
+
481
+ | `haptics` | Fires on |
482
+ |---|---|
483
+ | `"off"` | nothing |
484
+ | `"selection"` (default) | state changes: Switch, Checkbox, Toggle, ToggleGroup, SegmentedControl |
485
+ | `"all"` | selection plus a light tap on press-in for Button, pressable Card and Item, and `useScalePress` consumers |
486
+
487
+ Two ways to disable a `Button`. `disabled` swallows presses, skips the scale
488
+ and haptic, dims, and leaves the web tab order — for actions that cannot run.
489
+ `aria-disabled` (or `accessibilityState={{ disabled: true }}`) is announced
490
+ and dimmed the same way but stays focusable and pressable, so the press can say
491
+ why: a required question's "Next", a "Publish" without a title. Prefer it
492
+ whenever the user could otherwise wonder why nothing happens.
493
+
494
+ ```tsx
495
+ <Button text="Publish" aria-disabled={!title} onPress={() => (title ? publish() : notify.info("Add a title first"))} />
496
+ ```
497
+
498
+ Web never vibrates. A single `Button` can force either way with `haptic`
499
+ (`<Button haptic />`, `<Button haptic={false} />`); `useScalePress({ haptic })`
500
+ takes `true` (always), `false` (never) or `"setting"` (the default, follows the
501
+ provider). `hapticSuccess()` from `@mrmeg/expo-ui/lib` is there for completion
502
+ moments the app owns.
298
503
 
299
504
  ## Typography
300
505
 
@@ -312,6 +517,11 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
312
517
  - `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`, `eyebrow`
313
518
  - `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
314
519
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
520
+ - `italic`: a real italic face where the family has one (an app's `setFonts`
521
+ `italic` map, or the Newsreader serif preset below), otherwise
522
+ `fontStyle: "italic"` and the platform synthesizes the slant. Inter ships no
523
+ italic file on native (four upright weights, by design); on web the injected
524
+ Inter stylesheet carries the 400 italic, so body-weight italic is real there
315
525
  - `variant`: `sansSerif`, `serif`, `mono`
316
526
  - `align`, `text`, `tx`, `txOptions`
317
527
  - `selectable`: defaults to `true` on iOS and web and to `false` on Android
@@ -348,11 +558,15 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
348
558
 
349
559
  Inter is the sans face on every platform, in four static weights
350
560
  (`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`)
351
- from the bundled `@expo-google-fonts/inter`. Serif is Georgia; mono is the
352
- platform system monospace.
561
+ from the bundled `@expo-google-fonts/inter`. Serif is Georgia by default, or
562
+ Newsreader through the serif preset below; mono is the platform system
563
+ monospace.
353
564
 
354
565
  `useResources()` loads those four weights on native, so `StyledText`'s
355
- `light`–`bold` range resolves to real files instead of a faked OS bold. Icons
566
+ `light`–`bold` range resolves to real files instead of a faked OS bold. Each
567
+ weight is imported from its own `@expo-google-fonts/inter/<weight>` subpath, so
568
+ a native bundle carries those four files and no other Inter face, and a web
569
+ bundle carries no Inter file at all. Icons
356
570
  are SVG (`lucide-react-native`), so there is no icon font to load on any
357
571
  platform. On web
358
572
  it injects one Google Fonts Inter stylesheet (all four weights) after hydration
@@ -366,10 +580,82 @@ Expo Router web apps, add the links to app-owned `app/+html.tsx`:
366
580
  <link
367
581
  id="mrmeg-expo-ui-inter"
368
582
  rel="stylesheet"
369
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
583
+ href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
370
584
  />
371
585
  ```
372
586
 
587
+ ### Serif preset: Newsreader
588
+
589
+ Georgia is one face per platform, so `SerifText fontWeight="bold"` is a faux
590
+ bold and `italic` a synthesized slant. Newsreader is a real serif family —
591
+ four weights and a 400 italic — offered as an opt-in preset that costs an app
592
+ nothing unless it opts in: the package does not depend on the font package, so
593
+ the app adds it and hands the native files over.
594
+
595
+ ```bash
596
+ bun add @expo-google-fonts/newsreader
597
+ ```
598
+
599
+ ```ts
600
+ // lib/newsreaderFonts.native.ts — per-weight subpaths, so the native bundle
601
+ // carries exactly these five files. The web twin exports `null`.
602
+ import { Newsreader_400Regular } from "@expo-google-fonts/newsreader/400Regular";
603
+ import { Newsreader_400Regular_Italic } from "@expo-google-fonts/newsreader/400Regular_Italic";
604
+ import { Newsreader_500Medium } from "@expo-google-fonts/newsreader/500Medium";
605
+ import { Newsreader_600SemiBold } from "@expo-google-fonts/newsreader/600SemiBold";
606
+ import { Newsreader_700Bold } from "@expo-google-fonts/newsreader/700Bold";
607
+
608
+ export const newsreaderFontMap = {
609
+ Newsreader_400Regular,
610
+ Newsreader_500Medium,
611
+ Newsreader_600SemiBold,
612
+ Newsreader_700Bold,
613
+ Newsreader_400Regular_Italic,
614
+ };
615
+ ```
616
+
617
+ ```tsx
618
+ // where the app already calls useResources
619
+ const { loaded } = useResources({ serif: "newsreader", serifFonts: newsreaderFontMap });
620
+ ```
621
+
622
+ On native `useResources` registers the files and then sets the theme store's
623
+ `serifPreset` to `"newsreader"`, so serif text resolves to
624
+ `Newsreader_400Regular` … `Newsreader_700Bold` (`light` shares the 400 file)
625
+ and `italic` to `Newsreader_400Regular_Italic` at every weight. On web it
626
+ injects one Google Fonts stylesheet (`Newsreader:ital,wght@0,400;0,500;0,600;0,700;1,400`)
627
+ under the id `mrmeg-expo-ui-newsreader` — put an element with that id in
628
+ `+html.tsx` (the Google Fonts `<link>`, or self-hosted `@font-face` rules as
629
+ this template does from `@fontsource-variable/newsreader`) and the hook skips
630
+ its own — and switches the preset at once
631
+ (the stack falls back to Georgia until the faces arrive); weight is numeric on
632
+ the shared `"Newsreader"` family. A `setFonts` serif override wins over the
633
+ preset and skips its load. Without `serifFonts` on native the hook warns once
634
+ and keeps Georgia. The family names are exported as `newsreaderFamilies` from
635
+ `@mrmeg/expo-ui/constants`; `SerifPreset` and `useThemeStore().serifPreset` /
636
+ `setSerifPreset` are the store side.
637
+
638
+ ### Italic faces through `setFonts`
639
+
640
+ Each family group in `setFonts` takes an `italic` map beside the weights:
641
+
642
+ ```ts
643
+ useThemeStore.getState().setFonts({
644
+ families: {
645
+ sansSerif: {
646
+ regular: "Brand_Regular",
647
+ bold: "Brand_Bold",
648
+ italic: { regular: "Brand_Italic", bold: "Brand_BoldItalic" },
649
+ },
650
+ },
651
+ });
652
+ ```
653
+
654
+ `italic` on `StyledText` (or `useFontStyle(weight, variant, { italic: true })`)
655
+ then uses the italic face for its weight, falls back to the group's italic
656
+ `regular` when that weight has none, and emits no `fontStyle`; a group with no
657
+ `italic` map synthesizes on its upright face.
658
+
373
659
  ### Font overrides
374
660
 
375
661
  Forward your own faces with `setFonts`, once at startup after they are
@@ -431,7 +717,7 @@ building a new primitive.
431
717
  | `Badge` | Short status labels | Draft/active state, counts, plan and role tags |
432
718
  | `BottomSheet` | Mobile-first modal sheets | Action pickers, mobile filters, quick edit forms |
433
719
  | `Button` | Commands and CTAs | Submit, save, delete, navigation CTAs; loading state keeps the resting width |
434
- | `Card` | Framed content groups | List items, pricing plans, settings sections, dashboards |
720
+ | `Card` | One item in a collection, or a single tappable object; not a layout box | Feed entries, grid tiles, carousel slides, a tappable summary |
435
721
  | `Carousel` | Horizontally snapping slides with pressable dots | Testimonials, onboarding pages, image galleries |
436
722
  | `Checkbox` | Boolean selection | Terms consent, checklists, multi-select filters |
437
723
  | `Collapsible` | One-off disclosure | Advanced settings, hidden helper text |
@@ -444,20 +730,22 @@ building a new primitive.
444
730
  | `Icon` | Lucide or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
445
731
  | `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
446
732
  | `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
733
+ | `ItemGroup` | Flat grouped list: title, full-width `Item` rows with inset hairlines, footer | Settings sections, profile details, activity feeds |
447
734
  | `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
448
735
  | `Label` | Accessible form labels | Required, disabled, and group labels |
449
- | `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth panels |
736
+ | `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth forms |
450
737
  | `Notification` | Global toast surface | Saved/error/sync toasts, action toasts, loading toast |
738
+ | `Screen` | Page container: safe-area `edges`, one horizontal inset, theme background, optional scroll | Every screen root; under a header pass `edges={["bottom"]}` |
451
739
  | `Popover` | Anchored contextual content | Inline help, quick previews, small forms |
452
740
  | `Progress` | Determinate or indeterminate progress | Upload progress, onboarding completion |
453
741
  | `RadioGroup` | Mutually exclusive choices | Plan interval, visibility choice, survey answer |
454
- | `SectionHeader` | Eyebrow / title / description section intro | Landing sections, settings groups, report headers |
742
+ | `SectionHeader` | Eyebrow / title / description section intro | Landing sections, report headers, empty-screen intros |
455
743
  | `SegmentedControl` | Native segmented picker (`@expo/ui`) | Platform-native view switchers, iOS-style filters |
456
744
  | `Select` | Option menus | Country, category, status pickers; `label` drives default item text |
457
- | `Separator` | Horizontal or vertical dividers | Menu, section, and card dividers |
745
+ | `Separator` | Horizontal or vertical dividers | Section, menu, and toolbar dividers |
458
746
  | `Skeleton` | Loading placeholders | List, profile card, dashboard loading |
459
747
  | `Slider` | Numeric value selection (`@expo/ui`) | Volume, percentage, rating, threshold |
460
- | `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | KPI rows, analytics summaries, usage meters |
748
+ | `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | A scrolling KPI rail, one tappable metric |
461
749
  | `StatusBar` | Theme-aware native status bar | Root layout status styling |
462
750
  | `StyledText` and aliases | Theme-aware typography | Titles, labels, body copy, captions, translated text |
463
751
  | `Switch` | Binary settings | Notification, privacy, and feature toggles |
@@ -472,17 +760,32 @@ building a new primitive.
472
760
  `Icon` renders `lucide-react-native` — Lucide is a maintained superset of
473
761
  Feather in the same 24px, 2px round-stroke style. `name` is typed by
474
762
  `IconName`, the union of kebab-case Lucide names in the package registry
475
- (`src/components/icon-names.json`, about 150 names), so only the icons the package
476
- and its consumers name ship in the bundle: the root `lucide-react-native`
477
- entry (1,800+ icons) is never imported. `color` takes a theme color name or a
478
- literal; `decorative` hides the glyph from assistive tech.
763
+ (`src/components/icon-names.json`, about 150 names). Importing `Icon` includes
764
+ that named registry because `name` is looked up at runtime, even when a caller
765
+ uses `component`; the root `lucide-react-native` entry (1,800+ icons) is never
766
+ imported. `color` takes a theme color name or a
767
+ literal. `accessibilityLabel` names an icon that stands alone (`role="img"` +
768
+ `aria-label` on web, `accessible` + `accessibilityLabel` on native); an icon
769
+ without one is hidden from assistive tech — the glyph beside text that already
770
+ says it would otherwise be announced as an unnamed image — and `decorative`
771
+ only remains to silence a labelled icon. On web the SVG carries ARIA only
772
+ (`aria-hidden` or `role="img"`), never the RN-only accessibility props. Instead of `name`,
773
+ `Icon` and `Button.Icon` take `component`: any Lucide import (or another SVG
774
+ component that accepts `size` and `color`), sized, colored, themed, and hidden
775
+ from assistive tech exactly like a named icon. `Button.Icon` defaults its
776
+ color to the button's label color either way.
479
777
 
480
778
  ```tsx
481
- import { Icon } from "@mrmeg/expo-ui/components";
779
+ import { Button, Icon } from "@mrmeg/expo-ui/components";
780
+ import House from "lucide-react-native/icons/house";
482
781
  import Rocket from "lucide-react-native/icons/rocket";
483
782
 
484
783
  <Icon name="circle-check-big" color="success" size={16} />
485
784
  <Icon component={Rocket} color="accent" />
785
+ <Button onPress={goHome}>
786
+ <Button.Icon component={House} />
787
+ <Button.Text>Home</Button.Text>
788
+ </Button>
486
789
  ```
487
790
 
488
791
  Adding an icon: in this repo, add the kebab-case Lucide name to
@@ -518,7 +821,7 @@ Lucide equivalent; use `component` with your own SVG.
518
821
  | `Dialog` | `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose` |
519
822
  | `Drawer` | `DrawerTrigger`, `DrawerContent`, `DrawerHeader`, `DrawerBody`, `DrawerFooter`, `DrawerClose`, `DrawerToggleCollapse` |
520
823
  | `DropdownMenu` | `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuPortal`, `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` |
521
- | `Item` | `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
824
+ | `Item` | `ItemGroup`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
522
825
  | `Popover` | `PopoverTrigger`, `PopoverContent`, `PopoverHeader`, `PopoverBody`, `PopoverFooter` |
523
826
  | `RadioGroup` | `RadioGroupItem` |
524
827
  | `Select` | `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` |
@@ -544,19 +847,28 @@ are named exports only.
544
847
  | `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
545
848
  | `useFontStyle(weight?, variant?)` | `{ fontFamily, fontWeight? }` resolved through `setFonts` overrides |
546
849
  | `useReducedMotion()` | `true` when the OS asks for reduced motion |
850
+ | `useFocusVisible(options?)` | `{ focused, onFocus, onBlur }` for a `Pressable`; on web `focused` follows `:focus-visible` (keyboard only), so layer `getFocusRingStyle()` while it is true — the gate every kit control uses |
547
851
  | `useScalePress(options?)` | Animated style plus `onPressIn`/`onPressOut` for a `Pressable` (`scaleTo`, `haptic`, `disabled`) |
548
852
  | `useStaggeredEntrance(options?)` | Entrance animated style for list rows (`delay`; `STAGGER_DELAY` is 30) |
549
853
 
550
854
  `SsrViewportContext` (from `state`) supplies the first-render viewport width on
551
- web, where the window cannot be read during export or hydration.
855
+ web, where the window cannot be read during export or hydration. Right after
856
+ hydration `useDimensions()` switches to the real window. On web every consumer
857
+ shares one `resize` listener and one snapshot, re-renders only when the width or
858
+ height changes, and a component that mounts later reads the current viewport
859
+ straight away. The `mrmeg-vw` cookie (the width a server can seed the next
860
+ render from) is written once per page view and then once resizing settles.
552
861
 
553
862
  ### Patterns And Gotchas
554
863
 
555
864
  - Use `Button.preset`, not `variant`: `default` is the neutral primary action,
556
865
  `secondary` a neutral secondary surface, `outline` lower emphasis, `ghost`
557
866
  compact toolbars, `link` text-like commands, `destructive` dangerous actions.
558
- Native targets add computed hit slop up to 44px. Nested `StyledText` inherits
559
- the Button size, so use `size="sm"` for popover, tooltip, and toolbar
867
+ Every platform gives a Button a 44pt vertical target: native through computed
868
+ `hitSlop`, web through a transparent hit extender inside the drawn 28/32/40pt
869
+ box, so the visual size, layout and popover anchoring do not change; a caller
870
+ `hitSlop` replaces both (on web it is honoured too). Nested `StyledText`
871
+ inherits the Button size, so use `size="sm"` for popover, tooltip, and toolbar
560
872
  triggers.
561
873
  - A plain `View` whose `opacity` or `pointerEvents` follows state (`disabled`,
562
874
  `loading`, `checked`, `editable`) must be `collapsable={false}` on Android.
@@ -601,6 +913,18 @@ web, where the window cannot be read during export or hydration.
601
913
  rebuild; the sheet warns once in development on Android when the native core
602
914
  is older. Web has none. Do not wrap sheet content in another
603
915
  `KeyboardAvoidingView`: it would lift twice.
916
+ `onDismissed` (root prop) fires once per close after the sheet is fully gone;
917
+ present the next modal — a `Dialog` (an RN `Modal` on iOS), a native stack
918
+ modal — from it, not from `onOpenChange(false)` or a timer: on iOS the sheet
919
+ is still dismissing when `onOpenChange` fires and UIKit rejects a `Modal`
920
+ presented then ("already presenting") with no retry. Detection per platform:
921
+ iOS uses `@expo/ui`'s native `onDismiss` event, raised by SwiftUI
922
+ `.sheet(isPresented:onDismiss:)` after the dismissal transition; Android uses
923
+ `@expo/ui`'s close callback, raised after Material's hide animation for a
924
+ swipe / back / scrim dismissal and with the removal of the Compose sheet for a
925
+ prop-driven close (which has no exit animation); web derives it from the HTML
926
+ `<dialog>`'s `close` event, which `@expo/ui` raises when its exit animation
927
+ ends (immediately under reduced motion).
604
928
  The sheet hosts its content in a separate
605
929
  native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
606
930
  mounts its own tap-away keyboard-dismiss boundary on the content column: it
@@ -647,16 +971,41 @@ web, where the window cannot be read during export or hydration.
647
971
  not put `@expo/ui`-hosted controls inside them yet, and do not place a
648
972
  `Dialog` inside their content either — it has no view controller to present
649
973
  from there; render it at screen level and open it from the item's
650
- `onPress`. The iOS Modal is outside `UIProvider`'s root
651
- keyboard avoidance, so the dialog owns it there: the centered container is a
652
- package `KeyboardAvoidingView` (`behavior="padding"`), the card recenters
653
- above the keyboard with its fields and footer visible, and
654
- `useKeyboardAvoidance()` is `true` inside dialog content. Do not wrap dialog
655
- content in another `KeyboardAvoidingView`. The platform close request
974
+ `onPress`. Dialog content sits outside `UIProvider`'s root keyboard
975
+ avoidance on both native platforms (the iOS Modal is presented outside it;
976
+ the Android portal host is a sibling of it), so the dialog owns keyboard
977
+ avoidance on iOS and Android: the centered container is a package
978
+ `KeyboardAvoidingView` (`behavior="padding"`), the card recenters above the
979
+ keyboard with its fields and footer visible, and `useKeyboardAvoidance()` is
980
+ `true` inside dialog content. Do not wrap dialog content in another
981
+ `KeyboardAvoidingView`. Web has no software keyboard and no avoidance owner
982
+ (`useKeyboardAvoidance()` is `false` there). Dialog content also owns tap-away
983
+ keyboard dismissal on iOS and Android with the same boundary as
984
+ `DismissKeyboard` and `BottomSheet.Content`: `Dialog` carries it on the card
985
+ itself (the primitive's native content claims every touch inside the card so
986
+ the backdrop's close-on-press never fires there, and that claim would stop a
987
+ boundary on any ancestor from ever being asked), `AlertDialog` on its
988
+ centered container. A tap on the card's dead space (padding, labels, the gap
989
+ between fields and footer) hides the keyboard on release, a `Dialog` backdrop
990
+ tap closes the dialog and the keyboard with it, and `Close`, `Action`,
991
+ `Cancel`, buttons and fields still fire on the first tap; no
992
+ `DismissKeyboard` is needed inside a dialog (inert on web, which has no
993
+ software keyboard). The platform close request
656
994
  (hardware back, TV menu) routes to the root's `onOpenChange(false)`. Android
657
- and web render dialog content inline into the portal host, which sits
658
- outside the root avoidance, so an Android dialog does not avoid the keyboard
659
- yet.
995
+ and web render dialog content inline into the portal host.
996
+ - `PopoverContent` treats `side` as a preference. On iOS and Android it opens
997
+ on the other side when the content does not fit and there is more room
998
+ there, caps its height to the room it gets, and scrolls the children inside
999
+ that cap; `insets` default to the safe area, so the card stays clear of the
1000
+ status bar and home indicator. Pass `scrollable={false}` when the content
1001
+ brings its own `FlatList` (a VirtualizedList inside a vertical ScrollView
1002
+ warns): the cap still applies, but the popover can no longer measure its full
1003
+ height, so it stays on `side`. A `style` merges over the themed surface
1004
+ (background, border, radius, shadow) instead of replacing it. On web, Radix
1005
+ flips it and the card is capped to the space Radix measures; the primitive
1006
+ cannot pass Radix a collision padding, so a card wider than the room beside
1007
+ its trigger can sit flush against the viewport edge. A `PopoverTrigger` ref
1008
+ (`PopoverTriggerRef`) exposes `open()` and `close()`.
660
1009
  - `Carousel` renders every child (no virtualization), so slides survive into
661
1010
  the exported HTML shell and the first client frame; use `FlatList` for large
662
1011
  or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
@@ -693,19 +1042,39 @@ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
693
1042
  ### Quick Examples
694
1043
 
695
1044
  ```tsx
696
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@mrmeg/expo-ui/components";
697
-
698
- <Card variant="outline">
699
- <CardHeader>
700
- <CardTitle>Subscription</CardTitle>
701
- <Badge variant="secondary">Active</Badge>
702
- </CardHeader>
703
- <CardContent>
704
- <Button preset="default" fullWidth>
705
- Manage billing
706
- </Button>
707
- </CardContent>
708
- </Card>
1045
+ import {
1046
+ Badge,
1047
+ Icon,
1048
+ Item,
1049
+ ItemActions,
1050
+ ItemContent,
1051
+ ItemDescription,
1052
+ ItemGroup,
1053
+ ItemMedia,
1054
+ ItemTitle,
1055
+ } from "@mrmeg/expo-ui/components";
1056
+
1057
+ <ItemGroup title="Subscription" footer="Renews on the 1st of each month.">
1058
+ <Item>
1059
+ <ItemMedia icon="award" />
1060
+ <ItemContent>
1061
+ <ItemTitle>Pro plan</ItemTitle>
1062
+ <ItemDescription>$12 / month</ItemDescription>
1063
+ </ItemContent>
1064
+ <ItemActions>
1065
+ <Badge variant="secondary">Active</Badge>
1066
+ </ItemActions>
1067
+ </Item>
1068
+ <Item onPress={openBillingPortal}>
1069
+ <ItemMedia icon="credit-card" />
1070
+ <ItemContent>
1071
+ <ItemTitle>Manage billing</ItemTitle>
1072
+ </ItemContent>
1073
+ <ItemActions>
1074
+ <Icon name="chevron-right" size={18} color="mutedForeground" />
1075
+ </ItemActions>
1076
+ </Item>
1077
+ </ItemGroup>
709
1078
  ```
710
1079
 
711
1080
  ```tsx
@@ -747,23 +1116,45 @@ after 4s (`DEFAULT_NOTIFICATION_DURATION`) unless a `duration` is given;
747
1116
  auto-dismisses. `position` is `"top"` (default) or `"bottom"`. `globalUIStore`
748
1117
  stays available for reactive selectors and tests.
749
1118
 
1119
+ A bottom toast renders in the root layout, above every navigator, so a layout
1120
+ that owns a bar registers where it ends with `useNotificationOffset` from
1121
+ `@mrmeg/expo-ui/hooks`; the toast then sits 8 pt past the larger of the
1122
+ safe-area inset and the offset. The offset is measured from the window edge and
1123
+ includes the inset, so a JS tab layout passes `useBottomTabBarHeight()` as-is
1124
+ and a `NativeTabs` layout passes the platform bar height plus
1125
+ `useWindowInsets().bottom`. Pass `null` while the bar is hidden (keyboard up).
1126
+
1127
+ ```tsx
1128
+ import { Tabs } from "expo-router";
1129
+ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
1130
+ import { useNotificationOffset } from "@mrmeg/expo-ui/hooks";
1131
+
1132
+ function ToastOffset() {
1133
+ useNotificationOffset({ bottom: useBottomTabBarHeight() });
1134
+ return null;
1135
+ }
1136
+
1137
+ // Render <ToastOffset /> inside a tab screen (it must be a descendant of <Tabs>).
1138
+ ```
1139
+
750
1140
  ## Package Release
751
1141
 
752
1142
  ```sh
753
- bun run ui:release -- --patch --publish
1143
+ bun run pkg ui release -- --patch --publish
754
1144
  ```
755
1145
 
756
- Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0`. The
757
- command updates `packages/ui/package.json` and `bun.lock`, runs
758
- `bun run packages:peer-check` then the `ui:typecheck`, `ui:test`, `ui:build`,
759
- `ui:pack`, and `ui:consumer-smoke` gates, and publishes with
760
- `npm publish --access public` only when `--publish` is present (which also
761
- requires a working `npm whoami`). Without `--publish` it is the same bump and
762
- gate run as a dry run.
1146
+ Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0` (the
1147
+ committed version itself releases without a bump). The command updates
1148
+ `packages/ui/package.json` and `bun.lock`, runs `bun run packages:peer-check`
1149
+ then `bun run pkg ui typecheck`, `test`, and `build`, packs one tarball, runs
1150
+ `bun run pkg ui consumer-smoke -- --tarball <that tarball>`, and publishes that
1151
+ same tarball with `npm publish <tarball> --access public` only when
1152
+ `--publish` is present (which also requires a working `npm whoami`). Without
1153
+ `--publish` it is the same bump and gate run as a dry run.
763
1154
 
764
1155
  A clean working tree is required; commit first or pass `--allow-dirty`.
765
1156
 
766
- CI also installs and exports packed consumers against Expo 56 and 57
1157
+ CI also installs and exports packed consumers against Expo 56, 57, and 58
767
1158
  (`.github/workflows/package-compatibility.yml`).
768
1159
 
769
1160
  ### GitHub Publishing
@@ -773,18 +1164,20 @@ publishing:
773
1164
 
774
1165
  1. In npm package settings for `@mrmeg/expo-ui`, add a trusted publisher:
775
1166
  GitHub Actions, owner `mrmeg`, repository `expo-template`, workflow filename
776
- `publish-ui.yml`.
1167
+ `publish-packages.yml`.
777
1168
  2. Bump `packages/ui/package.json` in a commit and push it to `main`.
778
1169
 
779
- `publish-ui.yml` runs on a push to `main` touching `packages/ui/package.json`
780
- and on `workflow_dispatch`, using npm OIDC rather than a checked-in token or
781
- local npm login. On push it reads the committed version, skips cleanly if that
782
- version is already published, otherwise runs the gates and publishes from
783
- `packages/ui`. Manual runs take `version` (`patch`, `minor`, `major`, or exact
784
- `x.y.z`) and `ref` (default `main`); they bump the version, update `bun.lock`,
785
- run the gates, publish, then commit and push the bump. If a manual run fails
786
- after the bump landed, rerun it with the exact current version (e.g.
787
- `version=0.1.3`) — exact versions do not bump again.
1170
+ `publish-packages.yml` publishes every workspace package. It runs on a push to
1171
+ `main` that changes a `packages/*/package.json` and on `workflow_dispatch`, using
1172
+ npm OIDC rather than a checked-in token or local npm login. On push it releases
1173
+ the committed version when that version changed in the push and npm does not
1174
+ have it yet, through the same release script as the local command, then
1175
+ publishes the smoked tarball with provenance and tags `expo-ui-v<version>`.
1176
+ Manual runs take `package=ui`, `version` (`patch`, `minor`, `major`, or exact
1177
+ `x.y.z`), and `ref` (default `main`); they bump the version and commit it to
1178
+ `ref`, run the gates, publish, and tag. If a manual run fails after the bump
1179
+ landed, rerun it with the exact current version (e.g. `version=0.1.3`) — the
1180
+ committed version does not bump again.
788
1181
 
789
1182
  Keep `repository.url` in `package.json` as
790
1183
  `git+https://github.com/mrmeg/expo-template.git`: npm trusted publishing checks
@@ -799,22 +1192,30 @@ CI configuration, never in the repository.
799
1192
 
800
1193
  ```sh
801
1194
  bun run packages:peer-check
802
- bun run ui:typecheck
803
- bun run ui:test
804
- bun run ui:build
805
- bun run ui:pack
806
- bun run ui:consumer-smoke
1195
+ bun run pkg ui typecheck
1196
+ bun run pkg ui test
1197
+ bun run pkg ui build
1198
+ bun run pkg ui pack
1199
+ bun run pkg ui consumer-smoke
807
1200
  ```
808
1201
 
809
- `ui:pack` is a dry pack, so the published file list and package size can be
810
- inspected before release. `ui:consumer-smoke` installs the packed tarball into
811
- a clean fixture, checks every documented export-map target resolves,
812
- type-checks all public entrypoints, and runs an iOS `expo export` against the
813
- packed package at the workspace's Expo version, without a custom Metro config.
1202
+ `pkg ui pack` is a dry pack, so the published file list and package size can be
1203
+ inspected before release. `pkg ui consumer-smoke` installs the packed tarball
1204
+ into a clean fixture, checks every documented export-map target resolves,
1205
+ type-checks all public entrypoints, imports the Node-safe subpaths
1206
+ (`constants/spacing`, `constants/motion`, `components/keyboardFocusRegistry`,
1207
+ `state/notify` with `state/globalUIStore`, `state/SsrViewportContext`) in plain
1208
+ Node, and runs iOS, Android, and web `expo export`s against the packed package
1209
+ at the workspace's Expo version, without a custom Metro config. The web export's
1210
+ source maps must show one copy of React, react-dom, react-native-web, and every
1211
+ `@radix-ui/*` / `@rn-primitives/*` module: a consumer install that resolves two
1212
+ copies of `@radix-ui/react-slot` crashes the web `AlertDialog` with
1213
+ `React.Children.only expected to receive a single React element child`. Pass
1214
+ `-- --tarball <file>` to smoke an existing tarball instead of building one.
814
1215
 
815
1216
  ### Design-system manifest
816
1217
 
817
- `ui:build` also writes `dist/design-system.json`
1218
+ `pkg ui build` also writes `dist/design-system.json`
818
1219
  ([`scripts/build-design-system-manifest.mjs`](../../scripts/build-design-system-manifest.mjs)):
819
1220
  the spacing, radius and icon scales, the palette, the light and dark themes, the
820
1221
  font variants, every component's variant and size values, and `icons.names` (the
@@ -823,7 +1224,7 @@ rules use. It is exported as
823
1224
  `@mrmeg/expo-ui/design-system.json`, and `@mrmeg/eslint-plugin-expo-ui` reads it
824
1225
  in projects that install this package instead of checking out its sources — which
825
1226
  is what lets the design-system rules quote this release's presets and tokens.
826
- `ui:consumer-smoke` asserts the packed tarball carries it and that it parses at
1227
+ `pkg ui consumer-smoke` asserts the packed tarball carries it and that it parses at
827
1228
  `schemaVersion: 1` with a non-empty component list. The build fails rather than
828
1229
  writing an empty manifest, so a broken extractor cannot ship as "no rules to
829
1230
  enforce".