@oxyhq/bloom 1.0.0 → 1.0.2

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 (218) hide show
  1. package/README.md +16 -4
  2. package/docs/accordion.mdx +69 -0
  3. package/docs/activity-heatmap.mdx +71 -0
  4. package/docs/admonition.mdx +77 -0
  5. package/docs/alert-dialog.mdx +75 -0
  6. package/docs/alert.mdx +93 -0
  7. package/docs/animated-check.mdx +53 -0
  8. package/docs/aspect-ratio.mdx +45 -0
  9. package/docs/avatar-group.mdx +91 -0
  10. package/docs/avatar.mdx +126 -0
  11. package/docs/badge.mdx +74 -0
  12. package/docs/benefit-list.mdx +56 -0
  13. package/docs/bottom-sheet.mdx +103 -0
  14. package/docs/button.mdx +218 -0
  15. package/docs/card.mdx +106 -0
  16. package/docs/checkbox.mdx +59 -0
  17. package/docs/chip.mdx +53 -0
  18. package/docs/code.mdx +50 -0
  19. package/docs/combobox.mdx +64 -0
  20. package/docs/command.mdx +85 -0
  21. package/docs/composition-bar.mdx +63 -0
  22. package/docs/connection-dots.mdx +50 -0
  23. package/docs/connection-status.mdx +56 -0
  24. package/docs/content-panel.mdx +62 -0
  25. package/docs/context-menu.mdx +65 -0
  26. package/docs/design-tokens.mdx +254 -0
  27. package/docs/dialog.mdx +277 -0
  28. package/docs/divider.mdx +56 -0
  29. package/docs/dot-grid-meter.mdx +66 -0
  30. package/docs/dropdown-menu.mdx +161 -0
  31. package/docs/error-boundary.mdx +72 -0
  32. package/docs/fab.mdx +67 -0
  33. package/docs/field.mdx +70 -0
  34. package/docs/fill.mdx +58 -0
  35. package/docs/fonts.mdx +144 -0
  36. package/docs/frosted-icon-button.mdx +60 -0
  37. package/docs/getting-started.mdx +75 -0
  38. package/docs/grid.mdx +64 -0
  39. package/docs/hooks.mdx +81 -0
  40. package/docs/icon-circle.mdx +66 -0
  41. package/docs/icons.mdx +69 -0
  42. package/docs/image-aspect-ratio-cache.mdx +63 -0
  43. package/docs/image-resolver.mdx +66 -0
  44. package/docs/index.mdx +44 -0
  45. package/docs/input-group.mdx +58 -0
  46. package/docs/item.mdx +69 -0
  47. package/docs/kbd.mdx +57 -0
  48. package/docs/label.mdx +50 -0
  49. package/docs/link-preview.mdx +66 -0
  50. package/docs/list.mdx +72 -0
  51. package/docs/loading.mdx +129 -0
  52. package/docs/media-inset-border.mdx +52 -0
  53. package/docs/menubar.mdx +86 -0
  54. package/docs/migrating-to-1.0.mdx +434 -0
  55. package/docs/motion.mdx +83 -0
  56. package/docs/overlay.mdx +75 -0
  57. package/docs/popover.mdx +73 -0
  58. package/docs/portal.mdx +63 -0
  59. package/docs/pressable-scale.mdx +51 -0
  60. package/docs/profile-card.mdx +66 -0
  61. package/docs/progressive-blur.mdx +54 -0
  62. package/docs/prompt-input.mdx +183 -0
  63. package/docs/provider.mdx +64 -0
  64. package/docs/radio-indicator.mdx +60 -0
  65. package/docs/radio.mdx +65 -0
  66. package/docs/scroll.mdx +66 -0
  67. package/docs/search.mdx +67 -0
  68. package/docs/segmented-control.mdx +76 -0
  69. package/docs/select.mdx +93 -0
  70. package/docs/settings-list.mdx +186 -0
  71. package/docs/skeleton.mdx +66 -0
  72. package/docs/slider.mdx +73 -0
  73. package/docs/stat-bar.mdx +59 -0
  74. package/docs/stat-widgets.mdx +33 -0
  75. package/docs/styles.mdx +61 -0
  76. package/docs/subtle-hover.mdx +53 -0
  77. package/docs/surfaces.mdx +72 -0
  78. package/docs/switch.mdx +90 -0
  79. package/docs/tab-bar.mdx +386 -0
  80. package/docs/tabs.mdx +75 -0
  81. package/docs/text-field.mdx +187 -0
  82. package/docs/theme.mdx +67 -0
  83. package/docs/toast.mdx +285 -0
  84. package/docs/tooltip.mdx +67 -0
  85. package/docs/typography.mdx +88 -0
  86. package/docs/user-hover-card.mdx +140 -0
  87. package/docs/zoomable-image-gallery.mdx +66 -0
  88. package/lib/commonjs/design-tokens/color-roles.js +21 -1
  89. package/lib/commonjs/design-tokens/color-roles.js.map +1 -1
  90. package/lib/commonjs/design-tokens/index.js +6 -0
  91. package/lib/commonjs/design-tokens/index.js.map +1 -1
  92. package/lib/commonjs/design-tokens/tailwind-preset.js +3 -1
  93. package/lib/commonjs/design-tokens/tailwind-preset.js.map +1 -1
  94. package/lib/commonjs/design-tokens/theme-css.js +1 -0
  95. package/lib/commonjs/design-tokens/theme-css.js.map +1 -1
  96. package/lib/commonjs/design-tokens/theme.css +3 -0
  97. package/lib/commonjs/design-tokens/tokens-json.js +13 -4
  98. package/lib/commonjs/design-tokens/tokens-json.js.map +1 -1
  99. package/lib/commonjs/design-tokens/tokens.json +6227 -265
  100. package/lib/commonjs/index.js +7 -0
  101. package/lib/commonjs/index.js.map +1 -1
  102. package/lib/commonjs/index.web.js +7 -0
  103. package/lib/commonjs/index.web.js.map +1 -1
  104. package/lib/commonjs/theme/ambient-store.js +16 -1
  105. package/lib/commonjs/theme/ambient-store.js.map +1 -1
  106. package/lib/commonjs/theme/color-policy.js +30 -33
  107. package/lib/commonjs/theme/color-policy.js.map +1 -1
  108. package/lib/commonjs/theme/color-presets.js +413 -166
  109. package/lib/commonjs/theme/color-presets.js.map +1 -1
  110. package/lib/commonjs/theme/color-scope/seed-scope.js +1 -0
  111. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  112. package/lib/commonjs/theme/glass-colors.js +18 -21
  113. package/lib/commonjs/theme/glass-colors.js.map +1 -1
  114. package/lib/commonjs/theme/index.js +30 -0
  115. package/lib/commonjs/theme/index.js.map +1 -1
  116. package/lib/commonjs/theme/index.web.js +30 -0
  117. package/lib/commonjs/theme/index.web.js.map +1 -1
  118. package/lib/module/design-tokens/color-roles.js +20 -0
  119. package/lib/module/design-tokens/color-roles.js.map +1 -1
  120. package/lib/module/design-tokens/index.js +2 -2
  121. package/lib/module/design-tokens/index.js.map +1 -1
  122. package/lib/module/design-tokens/tailwind-preset.js +4 -2
  123. package/lib/module/design-tokens/tailwind-preset.js.map +1 -1
  124. package/lib/module/design-tokens/theme-css.js +2 -1
  125. package/lib/module/design-tokens/theme-css.js.map +1 -1
  126. package/lib/module/design-tokens/theme.css +3 -0
  127. package/lib/module/design-tokens/tokens-json.js +14 -5
  128. package/lib/module/design-tokens/tokens-json.js.map +1 -1
  129. package/lib/module/design-tokens/tokens.json +6227 -265
  130. package/lib/module/index.js +1 -1
  131. package/lib/module/index.js.map +1 -1
  132. package/lib/module/index.web.js +1 -1
  133. package/lib/module/index.web.js.map +1 -1
  134. package/lib/module/theme/ambient-store.js +16 -1
  135. package/lib/module/theme/ambient-store.js.map +1 -1
  136. package/lib/module/theme/color-policy.js +30 -33
  137. package/lib/module/theme/color-policy.js.map +1 -1
  138. package/lib/module/theme/color-presets.js +412 -165
  139. package/lib/module/theme/color-presets.js.map +1 -1
  140. package/lib/module/theme/color-scope/seed-scope.js +2 -1
  141. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  142. package/lib/module/theme/glass-colors.js +18 -21
  143. package/lib/module/theme/glass-colors.js.map +1 -1
  144. package/lib/module/theme/index.js +1 -1
  145. package/lib/module/theme/index.js.map +1 -1
  146. package/lib/module/theme/index.web.js +1 -1
  147. package/lib/module/theme/index.web.js.map +1 -1
  148. package/lib/typescript/commonjs/design-tokens/color-roles.d.ts +20 -0
  149. package/lib/typescript/commonjs/design-tokens/color-roles.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/design-tokens/index.d.ts +3 -3
  151. package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/design-tokens/tailwind-preset.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts.map +1 -1
  154. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +12 -3
  155. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -1
  156. package/lib/typescript/commonjs/index.d.ts +2 -2
  157. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  158. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  159. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  160. package/lib/typescript/commonjs/theme/ambient-store.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  162. package/lib/typescript/commonjs/theme/color-presets.d.ts +386 -62
  163. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  164. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  165. package/lib/typescript/commonjs/theme/glass-colors.d.ts +18 -21
  166. package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
  167. package/lib/typescript/commonjs/theme/index.d.ts +2 -2
  168. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  169. package/lib/typescript/commonjs/theme/index.web.d.ts +2 -2
  170. package/lib/typescript/commonjs/theme/index.web.d.ts.map +1 -1
  171. package/lib/typescript/module/design-tokens/color-roles.d.ts +20 -0
  172. package/lib/typescript/module/design-tokens/color-roles.d.ts.map +1 -1
  173. package/lib/typescript/module/design-tokens/index.d.ts +3 -3
  174. package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
  175. package/lib/typescript/module/design-tokens/tailwind-preset.d.ts.map +1 -1
  176. package/lib/typescript/module/design-tokens/theme-css.d.ts.map +1 -1
  177. package/lib/typescript/module/design-tokens/tokens-json.d.ts +12 -3
  178. package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -1
  179. package/lib/typescript/module/index.d.ts +2 -2
  180. package/lib/typescript/module/index.d.ts.map +1 -1
  181. package/lib/typescript/module/index.web.d.ts +2 -2
  182. package/lib/typescript/module/index.web.d.ts.map +1 -1
  183. package/lib/typescript/module/theme/ambient-store.d.ts.map +1 -1
  184. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  185. package/lib/typescript/module/theme/color-presets.d.ts +386 -62
  186. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  187. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  188. package/lib/typescript/module/theme/glass-colors.d.ts +18 -21
  189. package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
  190. package/lib/typescript/module/theme/index.d.ts +2 -2
  191. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  192. package/lib/typescript/module/theme/index.web.d.ts +2 -2
  193. package/lib/typescript/module/theme/index.web.d.ts.map +1 -1
  194. package/package.json +5 -2
  195. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  196. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  197. package/src/__tests__/support/press-host.ts +30 -0
  198. package/src/__tests__/support/rendered-style.ts +99 -0
  199. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  200. package/src/design-tokens/color-roles.ts +21 -0
  201. package/src/design-tokens/index.ts +3 -2
  202. package/src/design-tokens/tailwind-preset.ts +3 -2
  203. package/src/design-tokens/theme-css.ts +2 -1
  204. package/src/design-tokens/theme.css +3 -0
  205. package/src/design-tokens/tokens-json.ts +29 -9
  206. package/src/design-tokens/tokens.json +6227 -265
  207. package/src/glass/GlassSurface.tsx +2 -2
  208. package/src/index.ts +2 -0
  209. package/src/index.web.ts +2 -0
  210. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +4082 -0
  211. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
  212. package/src/theme/ambient-store.ts +21 -1
  213. package/src/theme/color-policy.ts +33 -38
  214. package/src/theme/color-presets.ts +473 -146
  215. package/src/theme/color-scope/seed-scope.ts +2 -1
  216. package/src/theme/glass-colors.ts +18 -21
  217. package/src/theme/index.ts +16 -1
  218. package/src/theme/index.web.ts +16 -1
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: Portal
3
+ description: Rendering somewhere else in the tree — and why the provider and outlet are native-only.
4
+ order: 38
5
+ ---
6
+
7
+ # Portal
8
+
9
+ `Portal` renders its children at an outlet instead of where they were written.
10
+ It is what lets a menu escape a parent's `overflow: hidden`, a clipping
11
+ scroll view, or a stacking context that would otherwise trap it.
12
+
13
+ ```tsx
14
+ import { Portal } from '@oxyhq/bloom/portal';
15
+
16
+ <View style={{ overflow: 'hidden' }}>
17
+ <Trigger />
18
+ {open ? (
19
+ <Portal>
20
+ <OverlayRoot>
21
+ <Panel />
22
+ </OverlayRoot>
23
+ </Portal>
24
+ ) : null}
25
+ </View>
26
+ ```
27
+
28
+ ## `PortalProvider` and `PortalOutlet` are NATIVE-ONLY
29
+
30
+ On web the fork portals directly to `document.body`, so both are explicit no-op
31
+ exports there. You still render them — the API is the same on both platforms —
32
+ they simply do nothing in a browser.
33
+
34
+ This matters because "this app doesn't mount a Portal Outlet" is **never** a
35
+ valid reason to fork a web-facing component. That reasoning once produced a
36
+ 742-line fork that diverged for two months and shipped strictly worse than the
37
+ thing it replaced.
38
+
39
+ ## Where the outlet goes
40
+
41
+ Near the app root, after the content it must paint over. Mounting it too early
42
+ puts every portaled surface behind the app; mounting two duplicates every
43
+ surface.
44
+
45
+ Outlets deliberately stay **out** of `BloomProvider`: their tree position is an
46
+ app decision, and a second mount is silent — you get two of everything, which
47
+ reads as a render loop rather than as a duplicated outlet.
48
+
49
+ ## Portal is not enough on its own
50
+
51
+ A portaled surface still needs:
52
+
53
+ - an [`OverlayRoot`](/docs/bloom/main/overlay) for its stack position and its
54
+ interactive root — the web portal root is `pointer-events: none` and that
55
+ **inherits**, so a surface without one is entirely click-through;
56
+ - a `Backdrop` if it should dismiss on an outside press.
57
+
58
+ ## Several portals coexist
59
+
60
+ Each portal is tracked by its own id, so opening a second does not replace the
61
+ first, and unmounting one removes only that one. Order at the outlet follows
62
+ mount order — which is also why the overlay stack, not the outlet, decides what
63
+ paints on top.
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: PressableScale
3
+ description: A press that shrinks slightly — the feedback for something that is not already a button.
4
+ order: 63
5
+ ---
6
+
7
+ # PressableScale
8
+
9
+ A `Pressable` that scales down while held.
10
+
11
+ ```tsx
12
+ import { PressableScale } from '@oxyhq/bloom/pressable-scale';
13
+
14
+ <PressableScale onPress={open} targetScale={0.97} accessibilityLabel="Open photo">
15
+ <Image source={photo} style={{ width: 120, height: 120, borderRadius: 12 }} />
16
+ </PressableScale>
17
+ ```
18
+
19
+ ## Which prop decides what
20
+
21
+ | prop | decides |
22
+ | ------------- | ------------------------------------------------------------ |
23
+ | `targetScale` | how far it shrinks while held — smaller is more emphatic |
24
+
25
+ Everything else is `Pressable`'s.
26
+
27
+ ## What it is for
28
+
29
+ Feedback on things that are not shaped like buttons: a photo, a card, a tile, a
30
+ row that opens something. Those have no border or fill to darken, so without
31
+ motion a press produces no acknowledgement at all and the user presses again.
32
+
33
+ `Button`, `Fab` and `FrostedIconButton` already have their own press feedback —
34
+ wrapping one adds a second animation on top of the first.
35
+
36
+ ## Keep the scale small
37
+
38
+ Around 0.96–0.98 for anything sizeable. A large element shrinking to 0.9 moves
39
+ its edges a long way, which reads as the layout jumping rather than as a press.
40
+ Small targets can take more, because the absolute movement is smaller.
41
+
42
+ ## It is a Pressable, so it still needs a name
43
+
44
+ An image inside a `PressableScale` announces as a button with no label.
45
+ `accessibilityLabel` is not optional here.
46
+
47
+ ## Native vs web
48
+
49
+ The press animation runs on the native driver, and jest can see the structure
50
+ but never whether the animation ran. A device build is the only place it is
51
+ actually verified.
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: ProfileCard
3
+ description: A profile widget with one headline number and one metric — four flavours, one shape.
4
+ order: 62
5
+ ---
6
+
7
+ # ProfileCard
8
+
9
+ A compact profile widget: an avatar with an optional ring and badge, a headline
10
+ value, and one metric underneath.
11
+
12
+ ```tsx
13
+ import { ProfileCard } from '@oxyhq/bloom/profile-card';
14
+
15
+ <ProfileCard
16
+ variant="wallet"
17
+ avatar={{ source: user.avatar, name: user.displayName }}
18
+ value="$167,395"
19
+ subtitle="*5bF5"
20
+ metric={{ kind: 'dots', label: 'Token diversity', filled: 34, total: 50 }}
21
+ footer={{ label: 'Top tokens', items: topTokens }}
22
+ />
23
+ ```
24
+
25
+ ## Which prop decides what
26
+
27
+ | prop | decides |
28
+ | ---------- | --------------------------------------------------------------- |
29
+ | `variant` | `wallet` · `social` · `shopping` · `stat` — the flavour |
30
+ | `layout` | `widget` (a fixed-width card) or `wide` (fills its container) |
31
+ | `avatar` | the image, its ring, a badge, and the fallback name |
32
+ | `value` | the headline number |
33
+ | `metric` | the thing underneath — a discriminated union, see below |
34
+ | `footer` | a labelled row of avatars |
35
+
36
+ ## `metric` is a discriminated union
37
+
38
+ `kind` picks both the widget and the fields it needs, so a `dots` metric cannot
39
+ be given a `max` and a `progress` metric cannot be given a `total`:
40
+
41
+ ```tsx
42
+ metric={{ kind: 'dots', filled: 34, total: 50 }}
43
+ metric={{ kind: 'progress', label: 'Level 4', value: 620, max: 1000 }}
44
+ ```
45
+
46
+ That is deliberate: the alternative — a flat prop bag with everything optional
47
+ — makes an incomplete metric a runtime surprise rather than a type error.
48
+
49
+ ## `avatar.source`, not a URL
50
+
51
+ The avatar goes through [`ImageResolver`](/docs/bloom/main/image-resolver), so
52
+ pass the bare Oxy file id. `name` is the fallback used to draw an initial when
53
+ there is no image — give it even when you have one, since that is exactly the
54
+ state you get when the image fails.
55
+
56
+ ## One headline number
57
+
58
+ `value` is the number the card exists to show. If a card needs two, it is two
59
+ cards — a widget with competing headlines has no headline, and the eye has
60
+ nowhere to land.
61
+
62
+ ## The background is deliberately not the `card` role
63
+
64
+ This widget sits **on** a card surface in its reference design, so it takes the
65
+ page background and reads as a cut-out rather than as a stacked card. If you
66
+ place it on the page background instead, give it a surface of its own.
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: ProgressiveBlur
3
+ description: A blur that fades out, so content dissolves into the chrome instead of meeting a line.
4
+ order: 72
5
+ ---
6
+
7
+ # ProgressiveBlur
8
+
9
+ A blur that is strongest at one edge and fades to nothing. Put it behind a
10
+ floating tab bar or a transparent header so scrolling content dissolves rather
11
+ than hitting a hard blur boundary halfway up a word.
12
+
13
+ ```tsx
14
+ import { ProgressiveBlur } from '@oxyhq/bloom/progressive-blur';
15
+
16
+ <ProgressiveBlur
17
+ direction="bottom"
18
+ intensity={5}
19
+ style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 96 }}
20
+ />
21
+ ```
22
+
23
+ ## Which prop decides what
24
+
25
+ | prop | decides |
26
+ | ----------- | ----------------------------------------------------- |
27
+ | `direction` | which edge the blur is anchored to — `top` or `bottom` |
28
+ | `intensity` | the strength at that edge, on `expo-blur`'s 0–100 scale |
29
+
30
+ It takes no size of its own: position and size it with `style`.
31
+
32
+ ## Two implementations, one call site
33
+
34
+ iOS has no public variable-blur API, so the **native** fork stacks ten thin
35
+ blur layers with a small per-layer intensity — each layer's edge adds an
36
+ imperceptible step, so the falloff reads as continuous. The **web** fork
37
+ replaces the whole stack with one masked `backdrop-filter`, because ten stacked
38
+ backdrop passes would cost far more than the single pass a browser can do
39
+ natively.
40
+
41
+ Nothing at the call site changes, and `intensity` means the same thing in both
42
+ — per layer on native, mapped onto the equivalent CSS radius on web.
43
+
44
+ ## Be conservative with `intensity`
45
+
46
+ The blur exists so text stays legible while passing under the chrome. A high
47
+ value turns the strip into an opaque band, which reads as a rendering bug
48
+ rather than as a design.
49
+
50
+ ## It needs something behind it
51
+
52
+ A backdrop blur with nothing scrolling under it is a slightly grey rectangle
53
+ that costs a compositing pass. If the area behind is a flat background, use a
54
+ gradient — or nothing.
@@ -0,0 +1,183 @@
1
+ ---
2
+ title: PromptInput
3
+ description: AI chat input — textarea, actions, attachments, submit, stop, fullscreen.
4
+ order: 17
5
+ ---
6
+
7
+ # PromptInput
8
+
9
+ The AI chat input component. Auto-resizing textarea, send / stop button, optional attachments, optional fullscreen toggle, configurable left-side actions, image paste on web, and a controlled or simple mode.
10
+
11
+ ## Simple mode
12
+
13
+ The fastest path to a working prompt input:
14
+
15
+ ```tsx
16
+ import { PromptInput } from '@oxyhq/bloom';
17
+
18
+ const [value, setValue] = useState('');
19
+ const [busy, setBusy] = useState(false);
20
+
21
+ <PromptInput
22
+ value={value}
23
+ onValueChange={setValue}
24
+ placeholder="Ask anything…"
25
+ isLoading={busy}
26
+ onSubmit={async () => {
27
+ setBusy(true);
28
+ try { await ask(value); setValue(''); } finally { setBusy(false); }
29
+ }}
30
+ onStop={() => abortController.abort()}
31
+ />
32
+ ```
33
+
34
+ That's it — textarea, submit button, stop button, all wired.
35
+
36
+ ## Compound mode
37
+
38
+ For full control over layout, opt into the compound API:
39
+
40
+ ```tsx
41
+ import * as Prompt from '@oxyhq/bloom/prompt-input';
42
+
43
+ <Prompt.Root value={value} onValueChange={setValue} isLoading={busy} onSubmit={ask}>
44
+ <Prompt.Textarea placeholder="Ask anything…" />
45
+ <Prompt.Actions>
46
+ <Prompt.AttachmentButton onPress={pickFile} />
47
+ <View style={{ flex: 1 }} />
48
+ <Prompt.SubmitButton />
49
+ </Prompt.Actions>
50
+ <Prompt.Attachments />
51
+ </Prompt.Root>
52
+ ```
53
+
54
+ Compound mode lets you reorder actions, render extra buttons, swap icons, or render attachments somewhere else entirely.
55
+
56
+ ## Attachments
57
+
58
+ ```tsx
59
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
60
+
61
+ <PromptInput
62
+ value={value}
63
+ onValueChange={setValue}
64
+ attachments={attachments}
65
+ onAddAttachment={(a) => setAttachments((prev) => [...prev, a])}
66
+ onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((p) => p.id !== id))}
67
+ onUpdateAttachment={(id, u) => setAttachments((prev) => prev.map((p) => p.id === id ? { ...p, ...u } : p))}
68
+ onImagePaste={(files) => {
69
+ /* turn each File into an Attachment and call onAddAttachment */
70
+ }}
71
+ />
72
+ ```
73
+
74
+ `onImagePaste` is web-only — the textarea swallows `paste` events and forwards image clipboard content.
75
+
76
+ ## Loading and stop
77
+
78
+ `isLoading` toggles the right-side button between "submit" and "stop". Wire `onStop` to abort the in-flight request:
79
+
80
+ ```tsx
81
+ const controllerRef = useRef<AbortController | null>(null);
82
+
83
+ <PromptInput
84
+ isLoading={busy}
85
+ onSubmit={async () => {
86
+ controllerRef.current = new AbortController();
87
+ setBusy(true);
88
+ try {
89
+ await ask(value, { signal: controllerRef.current.signal });
90
+ } catch (err) {
91
+ if ((err as Error).name === 'AbortError') return; // user pressed stop
92
+ throw err;
93
+ } finally {
94
+ setBusy(false);
95
+ }
96
+ }}
97
+ onStop={() => controllerRef.current?.abort()}
98
+ />
99
+ ```
100
+
101
+ ## Empty action
102
+
103
+ The right-side button defaults to the submit/stop pair. When the textarea is empty and not loading, you can render an alternate action (e.g. a mic for voice input):
104
+
105
+ ```tsx
106
+ <PromptInput
107
+ value={value}
108
+ onValueChange={setValue}
109
+ emptyAction={<MicButton onPress={() => startListening()} />}
110
+ />
111
+ ```
112
+
113
+ `emptyAction` only renders when `value` is empty and `isLoading` is false.
114
+
115
+ ## Fullscreen
116
+
117
+ Tap the expand icon to swap the input into a fullscreen modal. Override the icons:
118
+
119
+ ```tsx
120
+ <PromptInput
121
+ value={value}
122
+ onValueChange={setValue}
123
+ expandIcon={<MaximizeIcon />}
124
+ collapseIcon={<MinimizeIcon />}
125
+ />
126
+ ```
127
+
128
+ ## Props (simple mode)
129
+
130
+ | Prop | Type | Description |
131
+ |------|------|-------------|
132
+ | `value?` | `string` | Controlled text value. |
133
+ | `onValueChange?` | `(value: string) => void` | |
134
+ | `isLoading?` | `boolean` | Show stop button instead of submit. |
135
+ | `onSubmit?` | `() => void` | Submit handler. |
136
+ | `onStop?` | `() => void` | Abort handler. |
137
+ | `placeholder?` | `string` | Placeholder text. |
138
+ | `maxHeight?` | `number` | Max textarea height before it scrolls. Default `240`. |
139
+ | `disabled?` | `boolean` | |
140
+ | `actionsLeft?` | `React.ReactNode` | Render extra buttons on the left of the actions bar. |
141
+ | `emptyAction?` | `React.ReactNode` | Replaces the right-side button when empty + idle. |
142
+ | `attachments?` | `Attachment[]` | Controlled attachments. |
143
+ | `onAddAttachment?` | `(a: Attachment) => void` | |
144
+ | `onRemoveAttachment?` | `(id: string) => void` | |
145
+ | `onUpdateAttachment?` | `(id, updates) => void` | |
146
+ | `onImagePaste?` | `(files: File[]) => void` | Web-only image paste. |
147
+ | `disableKeyboardAvoidance?` | `boolean` | Skip the inner `KeyboardAvoidingView`. |
148
+ | `expandIcon?` | `React.ReactNode` | Custom expand icon. |
149
+ | `collapseIcon?` | `React.ReactNode` | Custom collapse icon. |
150
+ | `testID?` | `string` | |
151
+
152
+ ## Recipes
153
+
154
+ ### Send on Enter, newline on Shift+Enter
155
+
156
+ The textarea handles this on web automatically. On native, Enter inserts a newline (no software keyboard convention for "send" exists across iOS/Android). Drive submission via the submit button or an explicit toolbar action.
157
+
158
+ ### Disable submit while empty
159
+
160
+ The submit button is disabled automatically when `value.trim()` is empty. No extra wiring required.
161
+
162
+ ### Custom left action (model picker)
163
+
164
+ ```tsx
165
+ <PromptInput
166
+ value={value}
167
+ onValueChange={setValue}
168
+ actionsLeft={
169
+ <Select.Root value={model} onValueChange={setModel}>
170
+ <Select.Trigger label="Model">{({ props }) => <Button {...props}>{model}</Button>}</Select.Trigger>
171
+ <Select.Content
172
+ items={['gpt-4', 'gpt-3.5']}
173
+ renderItem={(m, _i, selected) => (
174
+ <Select.Item value={m} label={m}>
175
+ <Select.ItemText>{m}</Select.ItemText>
176
+ {selected === m ? <Select.ItemIndicator /> : null}
177
+ </Select.Item>
178
+ )}
179
+ />
180
+ </Select.Root>
181
+ }
182
+ />
183
+ ```
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: BloomProvider
3
+ description: The one root a consuming app mounts — and why the outlets are deliberately not in it.
4
+ order: 56
5
+ ---
6
+
7
+ # BloomProvider
8
+
9
+ The single root that composes Bloom's app-wide **state** providers, so none of
10
+ them lands at the wrong depth.
11
+
12
+ ```tsx
13
+ import { BloomProvider } from '@oxyhq/bloom/provider';
14
+
15
+ export default function RootLayout() {
16
+ return (
17
+ <BloomProvider>
18
+ <Stack />
19
+ </BloomProvider>
20
+ );
21
+ }
22
+ ```
23
+
24
+ ## Why one root rather than several
25
+
26
+ Depth is not a detail for these. Mounted too low:
27
+
28
+ - `useScrollRestoration()` **throws** for anything rendered beside it;
29
+ - `useMinimizeState()` silently hands out a private fallback, so two parts of
30
+ the app disagree about the same state and neither errors.
31
+
32
+ One root removes the chance to get that wrong.
33
+
34
+ ## Expo/expo-router apps only
35
+
36
+ `BloomProvider` binds the scroll store to `expoRouterScrollAdapter`, so it is
37
+ expo-router-only **by construction** — which is also why the family is not on
38
+ the root barrel: importing it would drag `expo-router` into every consumer's
39
+ graph, and Metro does not tree-shake, so an unmet peer is a build failure
40
+ rather than a degradation.
41
+
42
+ A Vite/SPA consumer mounts the pieces instead: `BloomThemeProvider` plus
43
+ `<ScrollRestorationProvider adapter={…}>` with its own router adapter.
44
+
45
+ ## The OUTLETS are deliberately not in it
46
+
47
+ `ToastOutlet`, `PortalProvider`/`PortalOutlet`, `SurfaceHost` and the dialog
48
+ hosts stay out. Where an outlet sits in the tree is an app decision — it has to
49
+ paint over the app's own chrome — and **a second mount duplicates every
50
+ surface**. Adding one here would silently double the toasts of any app that
51
+ already mounts its own.
52
+
53
+ That failure is quiet: two identical toasts read as a double-fire in your own
54
+ code, not as two outlets.
55
+
56
+ ## Where the theme provider goes
57
+
58
+ Above **every** render branch, including the splash/loading one.
59
+
60
+ `useTheme()` throws outside `BloomThemeProvider`, and the classic way to hit
61
+ that is an `appIsReady` early-return in `app/_layout.tsx` that renders a splash
62
+ screen *before* the providers mount. It fails at runtime only — `tsc` and the
63
+ build stay green — and only on a cold start. Verify in a real browser after any
64
+ theming migration.
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: RadioIndicator
3
+ description: The radio dot on its own — presentational, for rows that are already controls.
4
+ order: 69
5
+ ---
6
+
7
+ # RadioIndicator
8
+
9
+ The ring-and-dot glyph, with no label, no press target and no group.
10
+
11
+ ```tsx
12
+ import { RadioIndicator } from '@oxyhq/bloom/radio-indicator';
13
+
14
+ <Item
15
+ title={plan.name}
16
+ subtitle={plan.price}
17
+ leading={<RadioIndicator selected={plan.id === chosen} />}
18
+ role="radio"
19
+ onPress={() => setChosen(plan.id)}
20
+ />
21
+ ```
22
+
23
+ ## Which prop decides what
24
+
25
+ | prop | decides |
26
+ | --------------- | ----------------------------------------------- |
27
+ | `selected` | filled or hollow |
28
+ | `size` | the diameter; the dot scales with it |
29
+ | `selectedColor` | the fill when selected |
30
+ | `borderColor` | the ring when not |
31
+
32
+ ## Use it only where the row is already the control
33
+
34
+ `RadioIndicator` is **presentational**. It has no press handler and contributes
35
+ no role or state, so a group built out of these announces as decoration unless
36
+ the surrounding row supplies both — `role="radio"` and the selected state.
37
+
38
+ If you are building a plain list of options, use
39
+ [`Radio` / `RadioGroup`](/docs/bloom/main/radio) instead: they render the label,
40
+ make the whole row the target, and name the group. Reaching for the indicator
41
+ where you meant the control is the usual way a "radio group" ends up
42
+ unreachable by keyboard and silent to a screen reader.
43
+
44
+ The legitimate cases are rows that are already something else: a card selector,
45
+ a table row, a settings row with its own affordances.
46
+
47
+ ## The dot colour is derived, not white
48
+
49
+ When `selectedColor` is the theme primary, the dot uses the preset's readable
50
+ foreground — dark on a yellow primary, light on a blue one. A hardcoded white
51
+ dot disappears on a light accent, which is the same class of failure as
52
+ appending alpha to a token: correct markup, invisible control.
53
+
54
+ A caller-supplied `selectedColor` falls back to white, because Bloom cannot
55
+ know the right contrast for an arbitrary colour. If you pass one, check it.
56
+
57
+ ## Selected drops the border
58
+
59
+ A filled circle with a ring around it reads as two states at once. When
60
+ `selected` is true the border goes to zero and the fill carries the state.
package/docs/radio.mdx ADDED
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: Radio
3
+ description: One choice from a small set — the control, not just the dot.
4
+ order: 30
5
+ ---
6
+
7
+ # Radio
8
+
9
+ A radio is a **control**: the label is part of the target, the group announces
10
+ itself as a group, and exactly one option is selected. `RadioGroup` is the form
11
+ you almost always want; `Radio` is the single row, for when the options are not
12
+ a flat list.
13
+
14
+ ```tsx
15
+ import { RadioGroup } from '@oxyhq/bloom/radio';
16
+
17
+ <RadioGroup
18
+ label="Delivery speed"
19
+ value={speed}
20
+ onValueChange={setSpeed}
21
+ options={[
22
+ { value: 'standard', label: 'Standard', description: 'Two to four days' },
23
+ { value: 'express', label: 'Express', description: 'Next working day' },
24
+ { value: 'pickup', label: 'Collect in store', disabled: !storeNearby },
25
+ ]}
26
+ />
27
+ ```
28
+
29
+ ## Which prop decides what
30
+
31
+ | prop | decides |
32
+ | ------------------------- | --------------------------------------------------- |
33
+ | `label` (group) | the accessible name of the whole group — required |
34
+ | `value` / `onValueChange` | the selection; the group is fully controlled |
35
+ | `options` | the choices, each with its own `label`/`description` |
36
+ | `size` | `small` · `medium` · `large` |
37
+ | `disabled` | the whole group, or one option via its `disabled` |
38
+
39
+ ## The label belongs to the radio, not next to it
40
+
41
+ `Radio` renders its own `label` and `description` and makes the whole row the
42
+ press target. A dot with a separate `<Text>` beside it is a smaller target, and
43
+ tapping the words does nothing — which reads as an unresponsive control.
44
+
45
+ If you need the dot alone — inside a custom row, a card selector, a table cell —
46
+ that is [`RadioIndicator`](/docs/bloom/main/radio-indicator), which is
47
+ presentational and takes `selected`. Using the indicator where you meant the
48
+ control is how a "radio group" ends up announcing as decoration.
49
+
50
+ ## Radio, Switch or Checkbox
51
+
52
+ - **Radio** — one of several named options, all visible, mutually exclusive.
53
+ - **[Switch](/docs/bloom/main/switch)** — a single thing that is on or off,
54
+ taking effect immediately.
55
+ - **Checkbox** — several independent options, or an intent confirmed later.
56
+
57
+ Two radios is usually a sign the choice is really a switch — unless both
58
+ options need naming ("Metric" / "Imperial"), which is exactly when radios are
59
+ right.
60
+
61
+ ## A group with nothing selected
62
+
63
+ `value` may be `undefined`. Prefer that over pre-selecting an option the user
64
+ did not choose: a default that happens to be first is indistinguishable from an
65
+ answer, and it silently becomes the submitted value.
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: Scroll restoration
3
+ description: Returning to content where the user left it — and the decisions that make it feel right.
4
+ order: 55
5
+ ---
6
+
7
+ # Scroll restoration
8
+
9
+ `@oxyhq/bloom/scroll` remembers where the user was and puts them back. The core
10
+ is router-agnostic; one adapter binds it to a router.
11
+
12
+ ```tsx
13
+ import { ScrollRestorationProvider } from '@oxyhq/bloom/scroll';
14
+ import { expoRouterScrollAdapter } from '@oxyhq/bloom/scroll/expo-router';
15
+
16
+ <ScrollRestorationProvider adapter={expoRouterScrollAdapter}>
17
+ <App />
18
+ </ScrollRestorationProvider>
19
+ ```
20
+
21
+ An expo-router app gets this from [`BloomProvider`](/docs/bloom/main/provider)
22
+ already. A Vite/SPA consumer mounts it directly with its own adapter.
23
+
24
+ **The adapter must be a module-level constant** — the core calls its members as
25
+ hooks, so a value built inside a component changes the hook identity every
26
+ render.
27
+
28
+ ## The key is CONTENT, not the navigation entry
29
+
30
+ An offset is stored against the content's identity (route name plus sorted
31
+ params) and the caller's optional `key` — deliberately **not** against the
32
+ history entry.
33
+
34
+ So content the user has already seen restores however they arrived at it, and
35
+ only unseen content opens at the top. The accepted trade-off: the same content
36
+ open in two live entries shares one offset.
37
+
38
+ `contentId` comes from the adapter via `useRoute()`, never `usePathname()` —
39
+ `usePathname` reads the globally focused route, so a background screen would
40
+ adopt the foreground one's path and restore to a stranger's offset.
41
+
42
+ ## A miss RESETS to 0; it is not a no-op
43
+
44
+ On a document-scrolled web app, one window scroller serves every route. If
45
+ arriving at unseen content did nothing, it would inherit the previous screen's
46
+ offset — you would open a new page halfway down.
47
+
48
+ ## Native is deliberately narrower
49
+
50
+ The native hook is keyed on the storage key, never on focus, and it **returns**
51
+ `{ onScroll, restorePending }` for the caller to wire up rather than attaching
52
+ itself.
53
+
54
+ `enabled` is how the caller says *its rows exist yet* — restoring to an offset a
55
+ list cannot reach yet lands at the bottom of a short list. It is not a feature
56
+ flag.
57
+
58
+ ## What to expect on web
59
+
60
+ - The restore re-applies across a bounded run of frames, because a re-shown
61
+ virtualized list reaches full height over several.
62
+ - It **aborts on user input** (`wheel`, `touchstart`, `pointerdown`, `keydown`).
63
+ If the user starts scrolling, they have taken over.
64
+ - `history.scrollRestoration` is set to `'manual'` only while the provider is
65
+ mounted, and handed back on `pagehide` — so an app that unmounts it does not
66
+ leave the browser's own restoration disabled.