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