@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/LLM_USAGE.md CHANGED
@@ -74,6 +74,25 @@ avoidance is `react-native-keyboard-controller`, so mount its `KeyboardProvider`
74
74
  above `UIProvider`; use `KeyboardAvoidingView` directly only for a subtree with
75
75
  custom behavior.
76
76
 
77
+ Web setup, two more steps (every web theme color is a `var(--c-*)`, and the
78
+ theme store boots at `"system"`/light so the first client render matches the
79
+ server or exported HTML):
80
+
81
+ 1. `app/+html.tsx`: put `getThemeCssVariables()` (from `constants`) in the
82
+ global `<style>`, with the same overrides passed to `setColors` if the app
83
+ re-brands; without it every theme color is unset. Optionally add a blocking
84
+ inline script that sets `document.documentElement.dataset.theme` from
85
+ `localStorage[THEME_STORAGE_KEY]` (from `state`) or `prefers-color-scheme`,
86
+ so the first frame paints in the visitor's scheme.
87
+ 2. Root layout: `useEffect(() => syncThemeFromEnvironment(), []);`
88
+ (`syncThemeFromEnvironment` from `state`). It reads the persisted preference
89
+ and follows the OS color scheme, and returns its cleanup. Never call it
90
+ during render or at module scope. Safe to call more than once: calls share
91
+ one OS listener and each cleanup releases only its own call. `UIProvider`
92
+ does not call it; without it a web app stays on the boot default until the
93
+ user picks a theme. Optional on native, which loads the preference at
94
+ startup.
95
+
77
96
  `DismissKeyboard` owns tap dismissal; its ScrollView uses
78
97
  `keyboardShouldPersistTaps="always"` so RN cannot independently blur a hosted
79
98
  field after a non-scrolling drag. Use `always` for your own scroll view inside
@@ -105,6 +124,14 @@ give scroll views that contain a sheet `keyboardShouldPersistTaps="always"` (or
105
124
  when it sees such a tap. `Slider` and `SegmentedControl` are also
106
125
  `@expo/ui`-backed.
107
126
 
127
+ `BottomSheet` `onDismissed` fires once per close after the sheet is fully gone
128
+ on every platform (iOS: `@expo/ui`'s native SwiftUI `onDismiss` event, after
129
+ the transition; Android: `@expo/ui`'s post-animation close callback; web: the
130
+ HTML `<dialog>` `close` event at the end of the exit animation). Open the next
131
+ modal — a `Dialog`, a native stack modal — from `onDismissed`, never from
132
+ `onOpenChange(false)` or a timer: on iOS the sheet is still dismissing when
133
+ `onOpenChange` fires and a `Modal` presented then is rejected.
134
+
108
135
  `BottomSheet.Content` themes the native sheet surface with the card color. Pass
109
136
  `backgroundStyle={{ backgroundColor: "transparent" }}`, plus a `style` clearing
110
137
  the content column's card fill, when custom chrome such as a glass backdrop must
@@ -120,12 +147,26 @@ unfocusable boxes inside react-native-screens' `FullWindowOverlay`, which
120
147
  `Drawer`, `Popover`, `Select`, `DropdownMenu` and `Tooltip` still use on iOS —
121
148
  keep hosted controls out of those, and do not place a `Dialog` inside their
122
149
  content (no view controller to present from; render it at screen level and open
123
- it from the item's `onPress`). The dialog owns keyboard avoidance inside its Modal (package
124
- `KeyboardAvoidingView`, `behavior="padding"`; the card recenters above the
125
- keyboard and `useKeyboardAvoidance()` is `true` in dialog content); do not wrap
126
- dialog content in another `KeyboardAvoidingView`. Android and web render dialog
127
- content inline into the portal host, outside the root avoidance, so an Android
128
- dialog does not avoid the keyboard yet.
150
+ it from the item's `onPress`). The dialog owns keyboard avoidance on iOS and
151
+ Android (its content sits outside `UIProvider`'s root avoidance on both: the iOS
152
+ Modal and the Android portal host): the centered container is a package
153
+ `KeyboardAvoidingView` (`behavior="padding"`), the card recenters above the
154
+ keyboard with fields and footer visible, and `useKeyboardAvoidance()` is `true`
155
+ in dialog content; do not wrap dialog content in another `KeyboardAvoidingView`.
156
+ Web has no software keyboard and no avoidance owner. Dialog content owns tap-away
157
+ keyboard dismissal on iOS and Android (the same boundary as `DismissKeyboard`,
158
+ on the `Dialog` card itself and on the `AlertDialog` centered container:
159
+ dead-space taps hide the keyboard on release, a `Dialog` backdrop tap closes the
160
+ dialog and the keyboard with it, controls and fields fire on the first tap); do
161
+ not wrap dialog content in `DismissKeyboard` for that. Android and web render dialog content inline into
162
+ the portal host.
163
+
164
+ `PopoverContent` treats `side` as a preference: on iOS and Android it opens on
165
+ the other side when the content does not fit and there is more room there,
166
+ caps its height to the room and scrolls the children (pass `scrollable={false}`
167
+ for content with its own `FlatList`; it then stays on `side`). `insets` default
168
+ to the safe area, and a `style` merges over the themed surface rather than
169
+ replacing it. A `PopoverTrigger` ref (`PopoverTriggerRef`) has `open()` / `close()`.
129
170
 
130
171
  i18n is optional. Do not add app-level i18n setup just to use this package;
131
172
  plain children and `text` props work without `i18next` or `react-i18next`. `tx`
@@ -144,9 +185,15 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
144
185
  - Use `useTheme()` and semantic tokens instead of hardcoded colors.
145
186
  - Use `StyledText` or its semantic aliases instead of raw `Text` for app UI.
146
187
  - Use `Button.preset`, not `variant`.
188
+ - `disabled` blocks the press (no `onPress`, no scale/haptic, out of the web tab order). To keep a button focusable and pressable while announced and dimmed as disabled — so the press can explain why — use `aria-disabled` (or `accessibilityState={{ disabled: true }}`), never a hand-rolled opacity style.
147
189
  - Button visible heights: `sm` 28, `md` 32, `lg` 40. `TextInput`/`Select`: 32/36/40. `Toggle` sizes are `sm`/`default`/`lg` (32/36/40). `Tabs`: `sm`/`md` (32/36).
148
190
  - Use `Button size="sm"` for compact popover, tooltip, and toolbar triggers; nested `StyledText` inherits the Button size.
149
- - Use `notify` plus a root `UIProvider` for transient global feedback. (`globalUIStore` stays available for reactive subscriptions and tests.)
191
+ - Web SSR paints the visitor's scheme: wrap the root layout in `<InitialSchemeProvider scheme>` (`@mrmeg/expo-ui/state`) with the `"light"`/`"dark"` value read from a cookie on both server and client (template: `shared/ssrColorScheme.ts`, written by `+html.tsx` and `client/features/app/colorSchemeCookie.ts`); `useTheme()` uses it until `hasLoadedTheme`. Never read `localStorage` for the first render.
192
+ - A Radix-backed component (`Tabs`, `Accordion`, `Collapsible`, `Select`, `DropdownMenu`, `Popover`) on a server-rendered web route logs a `useId` hydration mismatch (Expo Router hydrates `#root`, the server rendered `+html.tsx`). Wrap that subtree — not the page — in `<Hydrated fallback={…}>` (`@mrmeg/expo-ui/components`) with a fallback that holds its height; never branch on `typeof window`. Content that must be in the server HTML stays outside the gate.
193
+ - Web console hygiene: animate with `useNativeDriver: shouldUseNativeDriver` (`@mrmeg/expo-ui/lib`), never `true`; set `pointerEvents` in `style`, never as a prop (react-native-web deprecates the prop, and its lazy warning module has overflowed the first SSR render of deep routes). `AnimatedView` folds a `pointerEvents` prop into style for you.
194
+ - Haptics are one setting: `<UIProvider haptics="off" | "selection" | "all">` (default `"selection"`: Switch/Checkbox/Toggle/ToggleGroup/SegmentedControl/ThemeSelector tap on change; `"all"` adds a light tap on press for Button, pressable Card and Item). Do not call `expo-haptics` per control; force one Button with `haptic`. Pressed/disabled looks come from `interaction.pressedOpacity` / `interaction.disabledOpacity` in `@mrmeg/expo-ui/constants`.
195
+ - Bottom toasts must clear the tab bar: the layout that owns a bar calls `useNotificationOffset({ bottom })` (`@mrmeg/expo-ui/hooks`) with the bar's distance from the window edge, inset included — `useBottomTabBarHeight()` for a JS `<Tabs>` layout, platform bar height + `useWindowInsets().bottom` for `NativeTabs` — and `null` while the bar is hidden. Never position a toast yourself.
196
+ - Use `notify` plus a root `UIProvider` for transient global feedback. (`globalUIStore` stays available for reactive subscriptions and tests. In a component, read it with zustand's `useStore(globalUIStore, selector)`, never `globalUIStore()`: the React Compiler only treats `use*` calls as hooks, caches the bare call, and the next render crashes with React error #311.)
150
197
  - Keep app monitoring, auth, API, and domain behavior outside this package.
151
198
 
152
199
  Semantic color tokens on `theme.colors`: `surfaceSunken`, `background`,
@@ -163,11 +210,19 @@ Token intent:
163
210
  - `surfaceSunken`: app-chrome surface one tier below `background`
164
211
  - `borderStrong`: hairline for elements on filled surfaces, where `border` blends in
165
212
 
166
- Elevation is a surface-tier ladder, not shadow depth: `surfaceSunken` (chrome) <
167
- `background` (content) < `card`/`popover` (raised) < `muted` (chips, insets).
213
+ Elevation is a surface-tier ladder plus, in dark, an edge highlight:
214
+ `surfaceSunken` (chrome) < `background` (content) < `card`/`popover` (raised) <
215
+ `muted` (chips, insets). `getShadowStyle` is scheme-aware: in dark the raised
216
+ presets (`subtle`, `soft`, `elevated`, `card`, `cardSubtle`, `cardHover`,
217
+ `glass`) start with a 1px inset white top highlight and use black shadows at
218
+ real alpha; in light it is the shadow alone, at about twice the old alpha. A
219
+ fork that overrides `background`, `card`, or `popover` should re-check against
220
+ the template's `/surface-lab`.
168
221
 
169
222
  On web every `theme.colors.*` value is a CSS custom property (`var(--c-*)`), so
170
- themes swap in CSS when `html[data-theme]` changes; native keeps literals. Hex
223
+ themes swap in CSS when `html[data-theme]` changes; native keeps literals. Both
224
+ web schemes share one `colors` object (`colors.light.colors ===
225
+ colors.dark.colors`); re-brand with `setColors`, never by mutating it. Hex
171
226
  alpha concatenation (`theme.colors.x + "15"`) does **not** work — use
172
227
  `withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` and from
173
228
  `useTheme()`. For sinks that cannot take `var()` (e.g. `<meta name="theme-color">`)
@@ -187,19 +242,29 @@ Use `useStyles()` for memoized theme-aware local styles. Its factory receives
187
242
 
188
243
  ```tsx
189
244
  const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
190
- card: {
191
- backgroundColor: withAlpha(theme.colors.primary, 0.08),
192
- padding: spacing.cardPadding,
245
+ content: {
246
+ paddingVertical: spacing.md,
247
+ gap: spacing.sectionSpacing,
248
+ },
249
+ selectedIcon: {
250
+ backgroundColor: withAlpha(theme.colors.primary, 0.12),
193
251
  },
194
252
  }));
195
253
  ```
196
254
 
197
255
  Layout spacing uses semantic density tokens, not raw scale steps:
198
- `spacing.screenPadding` (16) for screen and block gutters, `spacing.cardPadding`
199
- (16) for bordered panels, `spacing.sectionSpacing` (24) between grouped lists,
256
+ `spacing.screenPadding` (16) for the one horizontal inset of a screen or block,
257
+ `spacing.sectionSpacing` (24) between sections and `ItemGroup`s,
200
258
  `spacing.dialogPadding` (20) for dialogs, and `spacing.rowPaddingY`/`rowPaddingX`
201
259
  (10/16) with `spacing.rowGap` (12) for list rows. `Item` already applies the row
202
260
  tokens and keeps a 44px hit area on native while rendering 40px on web.
261
+ `spacing.cardPadding` (16) is the inner padding of `Card` and `StatCard` tiles,
262
+ not a layout inset. See Screen Layout below.
263
+
264
+ A settings screen picks the theme with one row: `ThemeSelector` in the row's
265
+ `ItemActions`, with translated `labels`. It reads and writes `useThemeStore`, so
266
+ it needs no state of its own. Describe the resolved scheme under the title only
267
+ while the preference is `system`.
203
268
 
204
269
  When the saved theme preference is `system`, the package theme store owns the OS
205
270
  color-scheme subscription, including web `prefers-color-scheme`. Do not add
@@ -222,10 +287,26 @@ sansSerif?, serif?, mono? }, webWeightStrategy? })` replaces the bundled faces
222
287
  (Inter / Georgia / system-mono) everywhere text renders; groups and weights are
223
288
  partial, missing weights fall back to that group's `regular`, and an overridden
224
289
  `sansSerif` makes `useResources` skip downloading Inter (call `setFonts` before
225
- mount for the skip). Use `webWeightStrategy: "family"` when loading per-weight
290
+ mount for the skip). Each group also takes `italic: { weight: face }`; `<StyledText
291
+ italic>` (or `useFontStyle(weight, variant, { italic: true })`) uses that face,
292
+ else the group's italic `regular`, else `fontStyle: "italic"` on the upright face.
293
+ `useResources({ serif: "newsreader", serifFonts })` swaps Georgia for the
294
+ Newsreader preset (four weights + a 400 italic): the app depends on
295
+ `@expo-google-fonts/newsreader` and passes the five per-weight faces on native;
296
+ web gets one stylesheet (`id="mrmeg-expo-ui-newsreader"`). A `setFonts` serif
297
+ override still wins. Use `webWeightStrategy: "family"` when loading per-weight
226
298
  faces through `expo-font` / `@expo-google-fonts`, `"numeric"` (default) for one
227
- multi-weight CSS family. `setShape({ button: { borderRadius?, withShadow? } })`
228
- re-shapes Buttons globally. Per-instance props and caller `style` always win.
299
+ multi-weight CSS family. `setShape({ button?, input?, card?, sheet?, badge?,
300
+ dialog? })` re-shapes a slot globally (`{ borderRadius }`; `button` also takes
301
+ `withShadow`): `input` reaches TextInput, Select and InputOTP; `card` reaches
302
+ Card, StatCard, bordered EmptyState and SkeletonCard; `sheet` the BottomSheet's
303
+ top corners; `dialog` Dialog and AlertDialog. Per-instance props and caller
304
+ `style` always win. Extra color tokens are declared once by augmenting
305
+ `ThemeColorExtensions` (`declare module "@mrmeg/expo-ui/constants" { interface
306
+ ThemeColorExtensions { brandGold: string } }`); they then type through
307
+ `setColors`, `ThemeColorScope`, `theme.colors`, and `getThemeCssVariables`
308
+ emits `--c-brand-gold` when the overrides name it. Provide every extension key
309
+ in both schemes.
229
310
 
230
311
  Enforce these rules mechanically with `@mrmeg/eslint-plugin-expo-ui`
231
312
  (`bun add -d @mrmeg/eslint-plugin-expo-ui`), which reports raw colors, off-scale
@@ -239,6 +320,60 @@ added ship one: the installed release has it when
239
320
  settings:
240
321
  https://raw.githubusercontent.com/mrmeg/expo-template/main/packages/lint/README.md
241
322
 
323
+ ## Screen Layout
324
+
325
+ Build screens flat. On a 390 pt phone every nested inset comes out of the
326
+ content: a settings group boxed inside a padded screen puts its rows 33 pt from
327
+ each edge (16 screen + 1 border + 16 row) and leaves 324 pt for them; the same
328
+ rows in an `ItemGroup` sit 16 pt in and get 358 pt.
329
+
330
+ - **Every screen root is `Screen`, with explicit `edges`.** `<Screen
331
+ edges={["bottom"]} scroll>` under a Stack header; `edges={["top"]}` above a
332
+ tab bar; `["top", "bottom"]` for a headerless screen or a modal; `[]` when the
333
+ navigator insets both. Never pad `insets.top` under a header or
334
+ `insets.bottom` above a tab bar (double padding), and never leave a headerless
335
+ screen without `"top"` (content under the Dynamic Island). `scroll` puts the
336
+ insets on the content container; `padded={false}` for full-bleed `ItemGroup`
337
+ rows; `contentContainerStyle` padding adds to the insets. Read insets with
338
+ `useWindowInsets()` (works inside modals and native sheets too), not
339
+ `useSafeAreaInsets()`.
340
+ - **One horizontal inset per screen: `spacing.screenPadding` (16).** Either a
341
+ container pads or its children do, never both. `Item` rows, and `ItemGroup`
342
+ titles and footers, carry the 16 themselves, so a scroll view of
343
+ `ItemGroup`s gets no horizontal padding; text, forms, and buttons between the
344
+ groups pad their own wrapper.
345
+ - **No boxes as layout.** Never wrap a section, a form, or a group of rows in a
346
+ bordered, shadowed, or tinted rounded `View`, or in a `Card`. Separate
347
+ sections with a header (an `ItemGroup` title, `SectionHeader`),
348
+ `spacing.sectionSpacing`, or a hairline (`Separator`).
349
+ - **Lists and settings: `ItemGroup` + `Item`.** Full-width rows and touch
350
+ targets, hairlines inset under the title, no card per row, no border around
351
+ the group. Pass the rows as direct children (a mapped array works; a
352
+ Fragment counts as one row) and leave `separator` off: the group draws the
353
+ line under every row but the last. A `Switch`, `Checkbox` or `Toggle` in
354
+ `ItemActions` is named by the row's `ItemTitle` automatically (its text as
355
+ `accessibilityLabel` / `aria-label`; a composed title links by id after
356
+ hydration); pass `accessibilityLabel` only when the title is not the right
357
+ name.
358
+ - **Forms:** fields span the column, grouped under section headers, not cards.
359
+ - **Primary content fills the width.** QR codes, photos, maps, media, and
360
+ charts size to the column (`width: "100%"` with `aspectRatio`, or a size
361
+ computed from the column width), never a fixed size floating in whitespace.
362
+ A photo library is a grid of width-sized tiles, not rows of 56 pt thumbnails.
363
+ - **Wide screens:** cap and centre the column instead of boxing it: `width:
364
+ "100%", maxWidth: 640, alignSelf: "center"` on the scroll view's
365
+ `contentContainerStyle` (960 for dense grids and dashboards).
366
+ - **When a `Card` is right:** one item in a collection (a feed entry, a grid
367
+ tile, a carousel slide) or a single tappable object. Its parts pad
368
+ `spacing.cardPadding`, a tile's inner padding. Never nest Cards or put one in
369
+ a padded panel. `StatCard` follows the same rule (tiles in a scrolling
370
+ metrics rail, or one tappable metric); a summary of numbers is flat text on
371
+ the gutter.
372
+ - Dialog, sheet, and popover chrome keep their own surfaces; these rules are
373
+ for screen content.
374
+
375
+ The first Minimal Example below is a complete flat screen.
376
+
242
377
  ## Component Use-Case Index
243
378
 
244
379
  Check this before creating a new app-local primitive. All components come from
@@ -253,7 +388,7 @@ Check this before creating a new app-local primitive. All components come from
253
388
  | `Badge` | Short status labels | Custom pill `View` + `Text` |
254
389
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets |
255
390
  | `Button` | Commands and CTAs | Pressable plus custom text styling |
256
- | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels |
391
+ | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | One item in a collection (feed entry, grid tile, carousel slide) or a single tappable object | Hand-rolled bordered tiles; never a box around a section, form, or row group |
257
392
  | `Carousel` | Horizontally snapping slide row with pressable dots | Snap `ScrollView` plus manual offset math |
258
393
  | `Checkbox` | Boolean selection | Custom checkmark controls |
259
394
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers |
@@ -263,13 +398,15 @@ Check this before creating a new app-local primitive. All components come from
263
398
  | `DropdownMenu` | Menus and command lists | Homemade popover menus |
264
399
  | `EmptyState` | No-data or recoverable error regions | One-off empty placeholders |
265
400
  | `ErrorBoundary` | React render error fallback | Unhandled screen crashes |
266
- | `Icon` | Lucide or custom icons with theme tokens | Raw vector icons with hardcoded colors |
401
+ | `Icon` | Registered Lucide glyphs or custom art with theme tokens | Raw vector icons with hardcoded colors |
267
402
  | `InputOTP` | Verification code entry | Several manually managed text inputs |
268
403
  | `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` | List / settings rows with density tokens | Hand-rolled row `View`s |
404
+ | `ItemGroup` | A titled group of full-width `Item` rows with inset hairlines and an optional footer | Bordered or shadowed boxes around rows, a `Card` per row, hand-drawn dividers |
269
405
  | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers |
270
406
  | `Label` | Accessible form labels | Plain styled text labels |
271
407
  | `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers |
272
408
  | `Notification` | Global toast surface | Screen-local toast state |
409
+ | `Screen` | Page container with required safe-area `edges`, one horizontal inset, optional scroll | Hand-rolled `useSafeAreaInsets` padding, `SafeAreaView` per screen |
273
410
  | `Popover` | Anchored contextual content | Custom anchored views |
274
411
  | `Progress` | Determinate or indeterminate progress | Layout-shifting spinners |
275
412
  | `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows |
@@ -279,13 +416,14 @@ Check this before creating a new app-local primitive. All components come from
279
416
  | `Separator` | Horizontal or vertical dividers | Border-only spacer views |
280
417
  | `Skeleton`, `SkeletonText`, `SkeletonAvatar`, `SkeletonCard` | Loading placeholders | Blank space or generic spinners |
281
418
  | `Slider` | Numeric value selection (`@expo/ui`) | Custom pan gesture track |
282
- | `StatCard` | Metric tile with label, value, unit, change | Hand-rolled dashboard cards |
419
+ | `StatCard` | Metric tile in a collection (a scrolling KPI rail) or one tappable metric | Hand-rolled dashboard tiles |
283
420
  | `StatusBar` | Theme-aware native status bar | Per-screen status-bar duplication |
284
421
  | `StyledText` and text aliases | Theme-aware typography | Raw `Text` with hardcoded styles |
285
422
  | `Switch` | Binary settings | Custom toggle switches |
286
423
  | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | In-page tabbed views | Custom segmented/tab controls |
287
424
  | `TextInput` | Text entry with label, helper/error text, clear, password reveal | Raw `TextInput` plus repeated label/error code |
288
- | `Toggle`, `ToggleIcon` | Pressed/unpressed control | Button with local selected styling |
425
+ | `ThemeSelector` | System / Light / Dark switch wired to the theme store; icon-only fits an `ItemActions` slot, `showLabels` stretches | Three radio rows or a hand-rolled theme picker |
426
+ | `Toggle`, `ToggleIcon` | Pressed/unpressed control; an `iconOnly` toggle needs `accessibilityLabel` (dev warns once) | Button with local selected styling |
289
427
  | `ToggleGroup`, `ToggleGroupItem`, `ToggleGroupIcon` | Single or multi toggle groups | Custom segmented controls |
290
428
  | `Tooltip` | Short hover/focus help | Persistent helper text or custom hover cards |
291
429
 
@@ -307,30 +445,96 @@ already scope label selectability; ordinary iOS/web text stays selectable.
307
445
 
308
446
  - `Button` commands · `Toggle` one pressed state · `ToggleGroup` a related set · `Switch` binary settings · `RadioGroup` few exclusive choices · `Select` longer option sets.
309
447
  - `Dialog` blocking decisions · `Popover` contextual controls · `Tooltip` short explanations · `DropdownMenu` action lists.
310
- - `Card` individual repeated or framed items, never a wrapper around full page sections · `EmptyState` no-data or recoverable errors · `Skeleton` loading content with stable layout · `Progress` real or indeterminate progress.
448
+ - `ItemGroup` + `Item` lists and settings · `Card` one item in a collection or one tappable object, never a wrapper around a section, form, or row group, never nested · `EmptyState` no-data or recoverable errors · `Skeleton` loading content with stable layout · `Progress` real or indeterminate progress.
311
449
  - `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive into the exported HTML shell and the first client frame; use `FlatList` for large or unbounded data. Dots are pressable and jump to their slide. A fractional `itemWidth` (default `0.85`) measures the viewport until the first layout, so pass absolute pixels (`> 1`) when the parent is narrower than the window and the first frame matters.
312
450
  - `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent, still loading, or failed, plus the default accessibility label. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring; the group's count is a hidden summary node, so each member stays individually announceable and the group needs no `accessible` wrapper.
313
451
  - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID` is the label's own id, `htmlFor` is the input's id (`<Label nativeID="email-label" htmlFor="email-input">` + `<TextInput nativeID="email-input" />`). One id on both renders duplicate ids on web and associates nothing. Prefer `TextInput`'s own `label` prop when no separate label element is needed.
314
452
  - A plain `View` whose `opacity`/`pointerEvents` follows state must be `collapsable={false}` on Android (Fabric re-parents its children on the flip; a flip racing a navigation pop crashes). Package surfaces are pinned; spread `stateSurfaceProps()` from `@mrmeg/expo-ui/lib` on app-owned ones. `Pressable` already pins itself.
315
453
  - `TextInput` `onBlur` fires only after a real focus on Android (the native field's first-composition blur is dropped in the package); validate on blur directly and do not add a touched-fields guard in app code.
316
454
  - `Drawer.Header` takes `icon`, `title`, and `action` slots for a compact app-brand row; put `Drawer.ToggleCollapse` in `action` for a trailing rail control. `Drawer.Content` owns safe-area top/bottom padding — do not duplicate it in children.
455
+ - `Icon` and `Button.Icon` take `name` (a kebab-case Lucide name typed `IconName`: the package builtins plus the names the app lists in `client/icons.json`, compiled with `bunx expo-ui-icons` and registered once with `registerIcons(appIcons)` at module scope in the root layout) or `component` (an `IconComponent` taking `size` and `color`, for art that is not a Lucide icon). Both are sized, colored with theme tokens, and hidden from assistive tech unless `accessibilityLabel` names a standalone icon (`decorative` only silences a labelled one); `Button.Icon` inherits the button's label color and is decorative by default. A missing name is a type error: run `bunx expo-ui-icons add <name>` (it maps an alias such as `home` to `house`) rather than importing an icon library. Check a name that arrives at runtime with `isIconName`.
317
456
 
318
457
  ## Minimal Examples
319
458
 
459
+ A flat settings screen: groups of full-width rows, one 16 pt inset, a capped
460
+ column on wide screens, no boxes.
461
+
320
462
  ```tsx
321
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@mrmeg/expo-ui/components";
322
-
323
- <Card variant="outline">
324
- <CardHeader>
325
- <CardTitle>Subscription</CardTitle>
326
- <Badge variant="secondary">Active</Badge>
327
- </CardHeader>
328
- <CardContent>
329
- <Button preset="default" fullWidth>
330
- Manage billing
331
- </Button>
332
- </CardContent>
333
- </Card>
463
+ import { useState } from "react";
464
+ import { ScrollView, StyleSheet, View } from "react-native";
465
+ import {
466
+ Button,
467
+ Icon,
468
+ Item,
469
+ ItemActions,
470
+ ItemContent,
471
+ ItemDescription,
472
+ ItemGroup,
473
+ ItemMedia,
474
+ ItemTitle,
475
+ Switch,
476
+ } from "@mrmeg/expo-ui/components";
477
+ import { spacing } from "@mrmeg/expo-ui/constants";
478
+
479
+ export function SettingsScreen({ onEditProfile, onSignOut }: { onEditProfile: () => void; onSignOut: () => void }) {
480
+ const [push, setPush] = useState(true);
481
+
482
+ return (
483
+ <ScrollView contentContainerStyle={styles.content}>
484
+ <ItemGroup title="Account">
485
+ <Item onPress={onEditProfile}>
486
+ <ItemMedia icon="user" />
487
+ <ItemContent>
488
+ <ItemTitle>Edit profile</ItemTitle>
489
+ </ItemContent>
490
+ <ItemActions>
491
+ <Icon name="chevron-right" size={18} color="mutedForeground" />
492
+ </ItemActions>
493
+ </Item>
494
+ <Item>
495
+ <ItemMedia icon="bell" />
496
+ <ItemContent>
497
+ <ItemTitle>Push notifications</ItemTitle>
498
+ <ItemDescription>Replies and mentions</ItemDescription>
499
+ </ItemContent>
500
+ <ItemActions>
501
+ <Switch checked={push} onCheckedChange={setPush} />
502
+ </ItemActions>
503
+ </Item>
504
+ </ItemGroup>
505
+
506
+ <ItemGroup title="About" footer="Version 1.4.0">
507
+ <Item>
508
+ <ItemContent>
509
+ <ItemTitle>Build</ItemTitle>
510
+ </ItemContent>
511
+ <ItemActions>
512
+ <ItemDescription>2026.09.25</ItemDescription>
513
+ </ItemActions>
514
+ </Item>
515
+ </ItemGroup>
516
+
517
+ {/* Not rows, so it pads its own wrapper to the same 16 pt gutter. */}
518
+ <View style={styles.gutter}>
519
+ <Button preset="destructive" fullWidth text="Sign out" onPress={onSignOut} />
520
+ </View>
521
+ </ScrollView>
522
+ );
523
+ }
524
+
525
+ const styles = StyleSheet.create({
526
+ // No horizontal padding: the rows carry the screen's inset.
527
+ content: {
528
+ width: "100%",
529
+ maxWidth: 640,
530
+ alignSelf: "center",
531
+ paddingVertical: spacing.md,
532
+ gap: spacing.sectionSpacing,
533
+ },
534
+ gutter: {
535
+ paddingHorizontal: spacing.screenPadding,
536
+ },
537
+ });
334
538
  ```
335
539
 
336
540
  ```tsx
@@ -359,7 +563,7 @@ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
359
563
  title="Acme"
360
564
  action={
361
565
  <Drawer.ToggleCollapse>
362
- <Icon name="sidebar" decorative />
566
+ <Icon name="panel-left" decorative />
363
567
  </Drawer.ToggleCollapse>
364
568
  }
365
569
  />