@oxy.so/bloom 2.12.4 → 3.0.1

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 (285) hide show
  1. package/README.md +4 -4
  2. package/docs/button-group.mdx +46 -4
  3. package/docs/composition.mdx +159 -0
  4. package/docs/field.mdx +39 -1
  5. package/docs/glass.mdx +61 -0
  6. package/docs/layout.mdx +72 -4
  7. package/docs/migrating-to-3.0.mdx +169 -0
  8. package/docs/page-header.mdx +237 -36
  9. package/lib/commonjs/button/Button.js +2 -0
  10. package/lib/commonjs/button/Button.js.map +1 -1
  11. package/lib/commonjs/button-group/ButtonGroup.js +124 -19
  12. package/lib/commonjs/button-group/ButtonGroup.js.map +1 -1
  13. package/lib/commonjs/control-surface/ControlSurface.js +46 -0
  14. package/lib/commonjs/control-surface/ControlSurface.js.map +1 -0
  15. package/lib/commonjs/control-surface/context.js +84 -0
  16. package/lib/commonjs/control-surface/context.js.map +1 -0
  17. package/lib/commonjs/control-surface/index.js +26 -0
  18. package/lib/commonjs/control-surface/index.js.map +1 -0
  19. package/lib/commonjs/control-surface/types.js +6 -0
  20. package/lib/commonjs/control-surface/types.js.map +1 -0
  21. package/lib/commonjs/field/Field.js +71 -26
  22. package/lib/commonjs/field/Field.js.map +1 -1
  23. package/lib/commonjs/field/context.js +80 -0
  24. package/lib/commonjs/field/context.js.map +1 -0
  25. package/lib/commonjs/field/index.js +7 -0
  26. package/lib/commonjs/field/index.js.map +1 -1
  27. package/lib/commonjs/floating/TriggerSlot.js +32 -0
  28. package/lib/commonjs/floating/TriggerSlot.js.map +1 -1
  29. package/lib/commonjs/glass/GlassIsland.js +107 -0
  30. package/lib/commonjs/glass/GlassIsland.js.map +1 -0
  31. package/lib/commonjs/glass/GlassSurface.js +4 -1
  32. package/lib/commonjs/glass/GlassSurface.js.map +1 -1
  33. package/lib/commonjs/glass/index.js +7 -0
  34. package/lib/commonjs/glass/index.js.map +1 -1
  35. package/lib/commonjs/index.js +564 -517
  36. package/lib/commonjs/index.js.map +1 -1
  37. package/lib/commonjs/index.web.js +564 -517
  38. package/lib/commonjs/index.web.js.map +1 -1
  39. package/lib/commonjs/label/Label.js +4 -1
  40. package/lib/commonjs/label/Label.js.map +1 -1
  41. package/lib/commonjs/layout/bottom-edge.js +3 -42
  42. package/lib/commonjs/layout/bottom-edge.js.map +1 -1
  43. package/lib/commonjs/layout/edge-store.js +65 -0
  44. package/lib/commonjs/layout/edge-store.js.map +1 -0
  45. package/lib/commonjs/layout/index.js +32 -0
  46. package/lib/commonjs/layout/index.js.map +1 -1
  47. package/lib/commonjs/layout/scroll-offset.js +83 -0
  48. package/lib/commonjs/layout/scroll-offset.js.map +1 -0
  49. package/lib/commonjs/layout/top-edge.js +109 -0
  50. package/lib/commonjs/layout/top-edge.js.map +1 -0
  51. package/lib/commonjs/page-header/EdgeScrim.js +136 -0
  52. package/lib/commonjs/page-header/EdgeScrim.js.map +1 -0
  53. package/lib/commonjs/page-header/PageHeader.js +236 -61
  54. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  55. package/lib/commonjs/provider/BloomProvider.js +5 -2
  56. package/lib/commonjs/provider/BloomProvider.js.map +1 -1
  57. package/lib/commonjs/social-button/OxyMark.js +2 -2
  58. package/lib/commonjs/social-button/OxyMark.js.map +1 -1
  59. package/lib/commonjs/social-button/SocialButton.js +2 -2
  60. package/lib/commonjs/social-button/SocialButton.js.map +1 -1
  61. package/lib/commonjs/styles/svg-id.js +29 -0
  62. package/lib/commonjs/styles/svg-id.js.map +1 -0
  63. package/lib/commonjs/text-field/TextField.js +39 -6
  64. package/lib/commonjs/text-field/TextField.js.map +1 -1
  65. package/lib/commonjs/theme/glass-colors.js +81 -1
  66. package/lib/commonjs/theme/glass-colors.js.map +1 -1
  67. package/lib/module/button/Button.js +2 -0
  68. package/lib/module/button/Button.js.map +1 -1
  69. package/lib/module/button-group/ButtonGroup.js +124 -19
  70. package/lib/module/button-group/ButtonGroup.js.map +1 -1
  71. package/lib/module/control-surface/ControlSurface.js +41 -0
  72. package/lib/module/control-surface/ControlSurface.js.map +1 -0
  73. package/lib/module/control-surface/context.js +77 -0
  74. package/lib/module/control-surface/context.js.map +1 -0
  75. package/lib/module/control-surface/index.js +5 -0
  76. package/lib/module/control-surface/index.js.map +1 -0
  77. package/lib/module/control-surface/types.js +4 -0
  78. package/lib/module/control-surface/types.js.map +1 -0
  79. package/lib/module/field/Field.js +72 -27
  80. package/lib/module/field/Field.js.map +1 -1
  81. package/lib/module/field/context.js +74 -0
  82. package/lib/module/field/context.js.map +1 -0
  83. package/lib/module/field/index.js +1 -0
  84. package/lib/module/field/index.js.map +1 -1
  85. package/lib/module/floating/TriggerSlot.js +33 -1
  86. package/lib/module/floating/TriggerSlot.js.map +1 -1
  87. package/lib/module/glass/GlassIsland.js +102 -0
  88. package/lib/module/glass/GlassIsland.js.map +1 -0
  89. package/lib/module/glass/GlassSurface.js +5 -2
  90. package/lib/module/glass/GlassSurface.js.map +1 -1
  91. package/lib/module/glass/index.js +1 -0
  92. package/lib/module/glass/index.js.map +1 -1
  93. package/lib/module/index.js +2 -1
  94. package/lib/module/index.js.map +1 -1
  95. package/lib/module/index.web.js +2 -1
  96. package/lib/module/index.web.js.map +1 -1
  97. package/lib/module/label/Label.js +4 -1
  98. package/lib/module/label/Label.js.map +1 -1
  99. package/lib/module/layout/bottom-edge.js +2 -41
  100. package/lib/module/layout/bottom-edge.js.map +1 -1
  101. package/lib/module/layout/edge-store.js +58 -0
  102. package/lib/module/layout/edge-store.js.map +1 -0
  103. package/lib/module/layout/index.js +8 -3
  104. package/lib/module/layout/index.js.map +1 -1
  105. package/lib/module/layout/scroll-offset.js +77 -0
  106. package/lib/module/layout/scroll-offset.js.map +1 -0
  107. package/lib/module/layout/top-edge.js +102 -0
  108. package/lib/module/layout/top-edge.js.map +1 -0
  109. package/lib/module/page-header/EdgeScrim.js +132 -0
  110. package/lib/module/page-header/EdgeScrim.js.map +1 -0
  111. package/lib/module/page-header/PageHeader.js +233 -58
  112. package/lib/module/page-header/PageHeader.js.map +1 -1
  113. package/lib/module/provider/BloomProvider.js +5 -2
  114. package/lib/module/provider/BloomProvider.js.map +1 -1
  115. package/lib/module/social-button/OxyMark.js +1 -1
  116. package/lib/module/social-button/OxyMark.js.map +1 -1
  117. package/lib/module/social-button/SocialButton.js +1 -1
  118. package/lib/module/social-button/SocialButton.js.map +1 -1
  119. package/lib/module/styles/svg-id.js +26 -0
  120. package/lib/module/styles/svg-id.js.map +1 -0
  121. package/lib/module/text-field/TextField.js +39 -6
  122. package/lib/module/text-field/TextField.js.map +1 -1
  123. package/lib/module/theme/glass-colors.js +79 -0
  124. package/lib/module/theme/glass-colors.js.map +1 -1
  125. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/button-group/ButtonGroup.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/button-group/types.d.ts +61 -5
  128. package/lib/typescript/commonjs/button-group/types.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts +4 -0
  130. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/control-surface/context.d.ts +21 -0
  132. package/lib/typescript/commonjs/control-surface/context.d.ts.map +1 -0
  133. package/lib/typescript/commonjs/control-surface/index.d.ts +4 -0
  134. package/lib/typescript/commonjs/control-surface/index.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/control-surface/types.d.ts +37 -0
  136. package/lib/typescript/commonjs/control-surface/types.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/field/Field.d.ts +1 -1
  138. package/lib/typescript/commonjs/field/Field.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/field/context.d.ts +55 -0
  140. package/lib/typescript/commonjs/field/context.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/field/index.d.ts +2 -0
  142. package/lib/typescript/commonjs/field/index.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/field/types.d.ts +22 -3
  144. package/lib/typescript/commonjs/field/types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/floating/TriggerSlot.d.ts.map +1 -1
  146. package/lib/typescript/commonjs/glass/GlassIsland.d.ts +4 -0
  147. package/lib/typescript/commonjs/glass/GlassIsland.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/glass/GlassSurface.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/glass/index.d.ts +2 -1
  150. package/lib/typescript/commonjs/glass/index.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/glass/types.d.ts +43 -0
  152. package/lib/typescript/commonjs/glass/types.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/index.d.ts +2 -1
  154. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  155. package/lib/typescript/commonjs/index.web.d.ts +2 -1
  156. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  157. package/lib/typescript/commonjs/label/Label.d.ts.map +1 -1
  158. package/lib/typescript/commonjs/label/types.d.ts +7 -1
  159. package/lib/typescript/commonjs/label/types.d.ts.map +1 -1
  160. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/layout/edge-store.d.ts +31 -0
  162. package/lib/typescript/commonjs/layout/edge-store.d.ts.map +1 -0
  163. package/lib/typescript/commonjs/layout/index.d.ts +8 -3
  164. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  165. package/lib/typescript/commonjs/layout/scroll-offset.d.ts +16 -0
  166. package/lib/typescript/commonjs/layout/scroll-offset.d.ts.map +1 -0
  167. package/lib/typescript/commonjs/layout/top-edge.d.ts +77 -0
  168. package/lib/typescript/commonjs/layout/top-edge.d.ts.map +1 -0
  169. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts +95 -0
  170. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts.map +1 -0
  171. package/lib/typescript/commonjs/page-header/PageHeader.d.ts +1 -1
  172. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  173. package/lib/typescript/commonjs/page-header/index.d.ts +1 -1
  174. package/lib/typescript/commonjs/page-header/index.d.ts.map +1 -1
  175. package/lib/typescript/commonjs/page-header/types.d.ts +97 -10
  176. package/lib/typescript/commonjs/page-header/types.d.ts.map +1 -1
  177. package/lib/typescript/commonjs/provider/BloomProvider.d.ts.map +1 -1
  178. package/lib/typescript/commonjs/styles/svg-id.d.ts +20 -0
  179. package/lib/typescript/commonjs/styles/svg-id.d.ts.map +1 -0
  180. package/lib/typescript/commonjs/text-field/TextField.d.ts.map +1 -1
  181. package/lib/typescript/commonjs/theme/glass-colors.d.ts +67 -0
  182. package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
  183. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  184. package/lib/typescript/module/button-group/ButtonGroup.d.ts.map +1 -1
  185. package/lib/typescript/module/button-group/types.d.ts +61 -5
  186. package/lib/typescript/module/button-group/types.d.ts.map +1 -1
  187. package/lib/typescript/module/control-surface/ControlSurface.d.ts +4 -0
  188. package/lib/typescript/module/control-surface/ControlSurface.d.ts.map +1 -0
  189. package/lib/typescript/module/control-surface/context.d.ts +21 -0
  190. package/lib/typescript/module/control-surface/context.d.ts.map +1 -0
  191. package/lib/typescript/module/control-surface/index.d.ts +4 -0
  192. package/lib/typescript/module/control-surface/index.d.ts.map +1 -0
  193. package/lib/typescript/module/control-surface/types.d.ts +37 -0
  194. package/lib/typescript/module/control-surface/types.d.ts.map +1 -0
  195. package/lib/typescript/module/field/Field.d.ts +1 -1
  196. package/lib/typescript/module/field/Field.d.ts.map +1 -1
  197. package/lib/typescript/module/field/context.d.ts +55 -0
  198. package/lib/typescript/module/field/context.d.ts.map +1 -0
  199. package/lib/typescript/module/field/index.d.ts +2 -0
  200. package/lib/typescript/module/field/index.d.ts.map +1 -1
  201. package/lib/typescript/module/field/types.d.ts +22 -3
  202. package/lib/typescript/module/field/types.d.ts.map +1 -1
  203. package/lib/typescript/module/floating/TriggerSlot.d.ts.map +1 -1
  204. package/lib/typescript/module/glass/GlassIsland.d.ts +4 -0
  205. package/lib/typescript/module/glass/GlassIsland.d.ts.map +1 -0
  206. package/lib/typescript/module/glass/GlassSurface.d.ts.map +1 -1
  207. package/lib/typescript/module/glass/index.d.ts +2 -1
  208. package/lib/typescript/module/glass/index.d.ts.map +1 -1
  209. package/lib/typescript/module/glass/types.d.ts +43 -0
  210. package/lib/typescript/module/glass/types.d.ts.map +1 -1
  211. package/lib/typescript/module/index.d.ts +2 -1
  212. package/lib/typescript/module/index.d.ts.map +1 -1
  213. package/lib/typescript/module/index.web.d.ts +2 -1
  214. package/lib/typescript/module/index.web.d.ts.map +1 -1
  215. package/lib/typescript/module/label/Label.d.ts.map +1 -1
  216. package/lib/typescript/module/label/types.d.ts +7 -1
  217. package/lib/typescript/module/label/types.d.ts.map +1 -1
  218. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  219. package/lib/typescript/module/layout/edge-store.d.ts +31 -0
  220. package/lib/typescript/module/layout/edge-store.d.ts.map +1 -0
  221. package/lib/typescript/module/layout/index.d.ts +8 -3
  222. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  223. package/lib/typescript/module/layout/scroll-offset.d.ts +16 -0
  224. package/lib/typescript/module/layout/scroll-offset.d.ts.map +1 -0
  225. package/lib/typescript/module/layout/top-edge.d.ts +77 -0
  226. package/lib/typescript/module/layout/top-edge.d.ts.map +1 -0
  227. package/lib/typescript/module/page-header/EdgeScrim.d.ts +95 -0
  228. package/lib/typescript/module/page-header/EdgeScrim.d.ts.map +1 -0
  229. package/lib/typescript/module/page-header/PageHeader.d.ts +1 -1
  230. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  231. package/lib/typescript/module/page-header/index.d.ts +1 -1
  232. package/lib/typescript/module/page-header/index.d.ts.map +1 -1
  233. package/lib/typescript/module/page-header/types.d.ts +97 -10
  234. package/lib/typescript/module/page-header/types.d.ts.map +1 -1
  235. package/lib/typescript/module/provider/BloomProvider.d.ts.map +1 -1
  236. package/lib/typescript/module/styles/svg-id.d.ts +20 -0
  237. package/lib/typescript/module/styles/svg-id.d.ts.map +1 -0
  238. package/lib/typescript/module/text-field/TextField.d.ts.map +1 -1
  239. package/lib/typescript/module/theme/glass-colors.d.ts +67 -0
  240. package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
  241. package/package.json +15 -1
  242. package/src/button/Button.tsx +10 -1
  243. package/src/button-group/ButtonGroup.tsx +146 -19
  244. package/src/button-group/types.ts +68 -5
  245. package/src/control-surface/ControlSurface.tsx +41 -0
  246. package/src/control-surface/context.ts +81 -0
  247. package/src/control-surface/index.ts +4 -0
  248. package/src/control-surface/types.ts +40 -0
  249. package/src/field/Field.tsx +84 -27
  250. package/src/field/context.ts +110 -0
  251. package/src/field/index.ts +3 -0
  252. package/src/field/types.ts +22 -3
  253. package/src/floating/TriggerSlot.tsx +35 -1
  254. package/src/glass/GlassIsland.tsx +109 -0
  255. package/src/glass/GlassSurface.tsx +26 -2
  256. package/src/glass/index.ts +3 -0
  257. package/src/glass/types.ts +45 -0
  258. package/src/index.ts +6 -0
  259. package/src/index.web.ts +6 -0
  260. package/src/label/Label.tsx +3 -1
  261. package/src/label/types.ts +7 -1
  262. package/src/layout/bottom-edge.tsx +3 -56
  263. package/src/layout/edge-store.ts +69 -0
  264. package/src/layout/index.ts +8 -3
  265. package/src/layout/scroll-offset.ts +76 -0
  266. package/src/layout/top-edge.tsx +109 -0
  267. package/src/page-header/EdgeScrim.tsx +118 -0
  268. package/src/page-header/PageHeader.tsx +253 -61
  269. package/src/page-header/index.ts +9 -1
  270. package/src/page-header/types.ts +103 -10
  271. package/src/provider/BloomProvider.tsx +12 -1
  272. package/src/social-button/OxyMark.tsx +1 -1
  273. package/src/social-button/SocialButton.tsx +1 -1
  274. package/src/styles/svg-id.ts +23 -0
  275. package/src/text-field/TextField.tsx +44 -5
  276. package/src/theme/glass-colors.ts +80 -0
  277. package/lib/commonjs/social-button/use-svg-id.js +0 -16
  278. package/lib/commonjs/social-button/use-svg-id.js.map +0 -1
  279. package/lib/module/social-button/use-svg-id.js +0 -13
  280. package/lib/module/social-button/use-svg-id.js.map +0 -1
  281. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts +0 -7
  282. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts.map +0 -1
  283. package/lib/typescript/module/social-button/use-svg-id.d.ts +0 -7
  284. package/lib/typescript/module/social-button/use-svg-id.d.ts.map +0 -1
  285. package/src/social-button/use-svg-id.ts +0 -10
@@ -16,32 +16,65 @@ import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
16
16
 
17
17
  import { Button } from '../button';
18
18
  import { BUTTON_SHADOW, mixColor, resolveButtonRamps } from '../button/shared';
19
+ import { ButtonGroupItem } from '../button-group';
20
+ import { ControlSurface } from '../control-surface';
21
+ import { GlassIsland } from '../glass';
19
22
  import { RiArrowLeftLine } from '../icons/remix';
23
+ import { useClaimTopEdge, useScrollOffset } from '../layout';
20
24
  import { BREAKPOINTS } from '../styles/breakpoints';
21
25
  import { WEB_POSITION_STICKY, type WebCssStyle } from '../styles/web-view-style';
22
26
  import { Z_INDEX } from '../styles/z-index';
23
27
  import { useTheme } from '../theme/use-theme';
24
28
  import { Text } from '../typography';
29
+ import { EdgeScrim, SCRIM_TAIL_RATIO } from './EdgeScrim';
25
30
  import type { PageHeaderProps } from './types';
26
31
 
27
32
  /**
28
- * `PageHeader`: a screen's top bar — back button, title (+ subtitle), leading
29
- * node and actions, composed from the vocabulary Bloom's template headers and
30
- * the settings modal's compact header use:
33
+ * `PageHeader`: a screen's top chrome.
31
34
  *
32
- * bar min-height 56, side inset 16 (below `sm`) / 24, gap 8
33
- * back secondary medium icon button (36, pill, shadow-xs), RiArrowLeftLine 20
34
- * start group gap 6 (template header `gap-1.5`)
35
- * title headline-medium, text-primary, px 4 (template `px-1`), one line
36
- * subtitle body-2-regular, text-secondary (neutral-500), one line
37
- * actions gap 10 (template `gap-2.5`)
38
- * background background/full — card / dark neutral-925
39
- * separator 1px separator-border — neutral-200 / dark neutral-800
40
- * shadow shadow-xs, fading in with scroll
35
+ * ── THE DEFAULT IS FLOATING ─────────────────────────────────────────────────
41
36
  *
42
- * Scroll-linked, not timed: border (`auto`), background (`transparent`) and
43
- * shadow (and, when `transparent`, the title) interpolate over `[0, scrollThreshold]` of `scrollY` — the caller's
44
- * shared value, else `window.scrollY` on web. No press scale.
37
+ * Not a bar. The back button sits in its OWN translucent capsule, the actions
38
+ * the consumer declared as a group share ONE island, the title floats between
39
+ * them, and the whole thing ends in a vertical gradient rather than a hairline.
40
+ * Content passes under it.
41
+ *
42
+ * Three things follow from that, and each is a prop rather than an assumption:
43
+ *
44
+ * `presentation` `floating` | `bar` — islands, or Bloom's flat strip
45
+ * `placement` `inline` | `overlay` — who reserves the space
46
+ * `scrim`/`titleReveal` — the edge effect and the title,
47
+ * separately
48
+ *
49
+ * The last pair used to be one flag. `transparent` faded the background in with
50
+ * scroll AND hid the title, which is right for a header over a hero photo and
51
+ * wrong everywhere else — and a header that inherited it got both behaviours
52
+ * whether or not it wanted either. They are two questions with two answers now.
53
+ *
54
+ * ── THE ISLANDS ARE THE CONSUMER'S GROUPING ─────────────────────────────────
55
+ *
56
+ * The header never infers grouping by reading its children's types. The
57
+ * `actions` slot is wrapped in a `ControlSurface` with `material: 'glass'`, so
58
+ * a `ButtonGroup` placed there becomes one island with no prop written on it,
59
+ * and two `ButtonGroup`s become two islands. That is a declaration the consumer
60
+ * makes, which is the only way the header can be right about which actions
61
+ * belong together.
62
+ *
63
+ * floating bar
64
+ * chrome islands + gradient opaque strip + hairline + shadow
65
+ * back 36 capsule, glass secondary medium icon `Button`
66
+ * title headline-medium, no capsule headline-medium
67
+ * scroll drives the scrim drives border, background, shadow
68
+ *
69
+ * The geometry both share:
70
+ *
71
+ * bar min-height 56, side inset 16 (below `sm`) / 24
72
+ * islands 36 tall (34 + hairline), 8 apart
73
+ * title headline-medium, text-primary, one line
74
+ * subtitle body-2-regular, secondary, one line
75
+ *
76
+ * Scroll-linked, not timed: everything interpolates over `[0, scrollThreshold]`
77
+ * of the resolved scroll offset. No press scale.
45
78
  */
46
79
 
47
80
  const BAR_HEIGHT = 56;
@@ -50,9 +83,26 @@ const INSET_WIDE = 24;
50
83
  const START_GAP = 6;
51
84
  const ROW_GAP = 8;
52
85
  const ACTIONS_GAP = 10;
86
+ const FLOATING_ACTIONS_GAP = 8;
53
87
  const TITLE_PADDING = 4;
54
88
  const DEFAULT_THRESHOLD = 20;
55
89
 
90
+ /**
91
+ * The narrowest the centred title may be squeezed to before it stops yielding
92
+ * to the two sides.
93
+ *
94
+ * Without a floor the centring inset is `max(startWidth, endWidth)` applied to
95
+ * BOTH sides, so on a narrow container two wide sides can ask for more than the
96
+ * container has — and `left + right > width` is a box of negative width, which
97
+ * on web collapses the title to nothing and on native logs a layout error. The
98
+ * title stops centring before that happens and simply sits off-centre, which is
99
+ * the lesser of the two wrongs and the one a reader can still use.
100
+ */
101
+ const MIN_CENTERED_TITLE_WIDTH = 96;
102
+
103
+ /** 44pt touch target from a 36pt capsule. Only for an island that stands alone. */
104
+ const CAPSULE_HIT_SLOP = { top: 4, bottom: 4, left: 4, right: 4 } as const;
105
+
56
106
  const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
57
107
 
58
108
  function PageHeaderComponent({
@@ -60,12 +110,17 @@ function PageHeaderComponent({
60
110
  subtitle,
61
111
  titleAlign = 'start',
62
112
  headingLevel = 1,
113
+ titleReveal = 'always',
114
+ presentation = 'floating',
115
+ placement = 'inline',
63
116
  onBack,
64
117
  backLabel = 'Back',
65
118
  leading,
66
119
  actions,
67
120
  border = 'auto',
68
121
  transparent = false,
122
+ scrim = 'auto',
123
+ scrimColor,
69
124
  scrollY: externalScrollY,
70
125
  scrollThreshold = DEFAULT_THRESHOLD,
71
126
  sticky = true,
@@ -74,12 +129,34 @@ function PageHeaderComponent({
74
129
  testID,
75
130
  }: PageHeaderProps) {
76
131
  const isWeb = Platform.OS === 'web';
132
+ const floating = presentation === 'floating';
133
+ const overlay = placement === 'overlay';
77
134
  const theme = useTheme();
78
- const { width } = useWindowDimensions();
79
135
  const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
80
136
  const padTop = (safeArea ?? !isWeb) ? insets.top : 0;
137
+
138
+ // The header sizes itself against its CONTAINER, not the window: a desktop
139
+ // split view puts a 380px panel next to a 1200px one, and a window-derived
140
+ // inset gives the narrow panel the wide padding. The window is only the seed
141
+ // for the first frame, before `onLayout` has reported.
142
+ const windowWidth = useWindowDimensions().width;
143
+ const [measuredWidth, setMeasuredWidth] = useState(0);
144
+ const [measuredHeight, setMeasuredHeight] = useState(0);
145
+ const width = measuredWidth || windowWidth;
146
+ const height = measuredHeight || padTop + BAR_HEIGHT;
81
147
  const sideInset = width >= BREAKPOINTS.sm ? INSET_WIDE : INSET_COMPACT;
82
148
 
149
+ const onContainerLayout = (e: LayoutChangeEvent) => {
150
+ setMeasuredWidth(e.nativeEvent.layout.width);
151
+ setMeasuredHeight(e.nativeEvent.layout.height);
152
+ };
153
+
154
+ // An overlaying header occupies no layout, so it declares what it covers and
155
+ // the content reads it back. Measured rather than computed: a second title
156
+ // line, an enlarged font or a notch all move it, and every one of those is a
157
+ // case a constant was written to survive and does not.
158
+ useClaimTopEdge(overlay ? height : 0);
159
+
83
160
  const paint = useMemo(() => {
84
161
  const { neutral } = resolveButtonRamps(theme);
85
162
  return theme.isDark
@@ -97,19 +174,26 @@ function PageHeaderComponent({
97
174
  };
98
175
  }, [theme]);
99
176
 
177
+ // ── The scroll owner ──────────────────────────────────────────────────────
178
+ //
179
+ // Prop, then the nearest `ScrollOffsetProvider`, then the document on web.
180
+ // The context step is the one that matters for a Bloom composition: without
181
+ // it a header inside a scrolling panel followed `window.scrollY`, which on a
182
+ // desktop shell never moves, so the header looked deliberately inert.
183
+ const contextScrollY = useScrollOffset();
100
184
  const internalScrollY = useSharedValue(0);
101
- const scrollY = externalScrollY ?? internalScrollY;
185
+ const scrollY = externalScrollY ?? contextScrollY ?? internalScrollY;
186
+ const followsWindow = !externalScrollY && !contextScrollY;
102
187
 
103
- // Web fallback: the document is the scroll owner, as in the fleet headers.
104
188
  useEffect(() => {
105
- if (!isWeb || externalScrollY || typeof window === 'undefined') return undefined;
189
+ if (!isWeb || !followsWindow || typeof window === 'undefined') return undefined;
106
190
  const onScroll = () => {
107
191
  internalScrollY.value = window.scrollY;
108
192
  };
109
193
  onScroll();
110
194
  window.addEventListener('scroll', onScroll, { passive: true });
111
195
  return () => window.removeEventListener('scroll', onScroll);
112
- }, [isWeb, externalScrollY, internalScrollY]);
196
+ }, [isWeb, followsWindow, internalScrollY]);
113
197
 
114
198
  const threshold = Math.max(1, scrollThreshold);
115
199
 
@@ -123,12 +207,23 @@ function PageHeaderComponent({
123
207
  : 1,
124
208
  }), [scrollY, threshold, transparent]);
125
209
 
126
- // Over media the title would sit on the photo; it arrives with the bar.
210
+ const scrimStyle = useAnimatedStyle(() => ({
211
+ opacity:
212
+ scrim === 'always'
213
+ ? 1
214
+ : scrim === 'none'
215
+ ? 0
216
+ : interpolate(scrollY.value, [0, threshold], [0, 1], Extrapolation.CLAMP),
217
+ }), [scrollY, threshold, scrim]);
218
+
219
+ // Separate from the background's: a header can be transparent and still name
220
+ // its screen, and one over a hero photo can do the opposite.
127
221
  const titleStyle = useAnimatedStyle(() => ({
128
- opacity: transparent
129
- ? interpolate(scrollY.value, [0, threshold], [0, 1], Extrapolation.CLAMP)
130
- : 1,
131
- }), [scrollY, threshold, transparent]);
222
+ opacity:
223
+ titleReveal === 'always'
224
+ ? 1
225
+ : interpolate(scrollY.value, [0, threshold], [0, 1], Extrapolation.CLAMP),
226
+ }), [scrollY, threshold, titleReveal]);
132
227
 
133
228
  const borderStyle = useAnimatedStyle(() => ({
134
229
  opacity:
@@ -139,14 +234,25 @@ function PageHeaderComponent({
139
234
  : interpolate(scrollY.value, [0, threshold], [0, 1], Extrapolation.CLAMP),
140
235
  }), [scrollY, threshold, border]);
141
236
 
142
- // Centred title: inset it by the wider side so it sits on the bar's centre
143
- // whatever the two sides hold.
144
- const [startWidth, setStartWidth] = useState(0);
145
- const [endWidth, setEndWidth] = useState(0);
237
+ // ── Centring ──────────────────────────────────────────────────────────────
238
+ //
239
+ // Inset the title by the WIDER side so it sits on the container's centre
240
+ // whatever the two sides hold. A slot that is not rendered contributes 0
241
+ // rather than its last measurement: `onLayout` does not fire on unmount, so a
242
+ // header that loses its actions would otherwise keep centring around them.
243
+ const [startMeasure, setStartMeasure] = useState(0);
244
+ const [endMeasure, setEndMeasure] = useState(0);
146
245
  const centered = titleAlign === 'center';
147
- const onStartLayout = (e: LayoutChangeEvent) => setStartWidth(e.nativeEvent.layout.width);
148
- const onEndLayout = (e: LayoutChangeEvent) => setEndWidth(e.nativeEvent.layout.width);
149
- const centerInset = Math.max(startWidth, endWidth) + ROW_GAP;
246
+ const hasStart = onBack != null || leading != null;
247
+ const hasEnd = actions != null;
248
+ const startWidth = hasStart ? startMeasure : 0;
249
+ const endWidth = hasEnd ? endMeasure : 0;
250
+ const onStartLayout = (e: LayoutChangeEvent) => setStartMeasure(e.nativeEvent.layout.width);
251
+ const onEndLayout = (e: LayoutChangeEvent) => setEndMeasure(e.nativeEvent.layout.width);
252
+ const centerInset = Math.min(
253
+ Math.max(startWidth, endWidth) + ROW_GAP,
254
+ Math.max(0, (width - 2 * sideInset - MIN_CENTERED_TITLE_WIDTH) / 2),
255
+ );
150
256
 
151
257
  const titleBlock =
152
258
  title != null || subtitle != null ? (
@@ -187,14 +293,61 @@ function PageHeaderComponent({
187
293
  </Animated.View>
188
294
  ) : null;
189
295
 
296
+ const backButton = onBack ? (
297
+ floating ? (
298
+ // Its own island: a back action is not a member of the page's action
299
+ // group, and a `role="group"` of one is noise a screen reader reads out.
300
+ // The control inside is a `ButtonGroupItem` because that is Bloom's
301
+ // island-aware control — it reads the material `GlassIsland` publishes
302
+ // and paints flush — rather than a second implementation of the same
303
+ // hover, press, focus and disabled behaviour.
304
+ <GlassIsland testID={testID ? `${testID}-back-island` : undefined}>
305
+ <ButtonGroupItem
306
+ iconOnly
307
+ leadingIcon={RiArrowLeftLine}
308
+ accessibilityLabel={backLabel}
309
+ onPress={onBack}
310
+ hitSlop={CAPSULE_HIT_SLOP}
311
+ testID={testID ? `${testID}-back` : undefined}
312
+ />
313
+ </GlassIsland>
314
+ ) : (
315
+ <Button
316
+ variant="secondary"
317
+ size="medium"
318
+ iconOnly
319
+ leadingIcon={RiArrowLeftLine}
320
+ accessibilityLabel={backLabel}
321
+ onPress={onBack}
322
+ testID={testID ? `${testID}-back` : undefined}
323
+ />
324
+ )
325
+ ) : null;
326
+
190
327
  const containerWeb: WebCssStyle | null =
191
- isWeb && sticky ? { position: WEB_POSITION_STICKY, top: 0, zIndex: Z_INDEX.floating } : null;
328
+ isWeb && sticky && !overlay
329
+ ? { position: WEB_POSITION_STICKY, top: 0, zIndex: Z_INDEX.floating }
330
+ : null;
192
331
 
193
- return (
194
- <View
195
- testID={testID}
196
- style={[styles.container, { paddingTop: padTop }, containerWeb, style]}
332
+ const chrome = floating ? (
333
+ <Animated.View
334
+ // A PROP: react-native-web resolves `none` from the prop path only, and
335
+ // the scrim must never take a press meant for the content under it.
336
+ pointerEvents="none"
337
+ style={[styles.scrim, { height: height * (1 + SCRIM_TAIL_RATIO) }, scrimStyle]}
338
+ testID={testID ? `${testID}-scrim` : undefined}
197
339
  >
340
+ <EdgeScrim
341
+ // The PAGE colour by default: the scrim's job is to fade content into
342
+ // the surface it is leaving, so the right colour is the one that
343
+ // surface already is. A screen that paints its own background has to
344
+ // say so — the header cannot read the pixel behind it.
345
+ color={scrimColor ?? theme.colors.background}
346
+ testID={testID ? `${testID}-scrim-gradient` : undefined}
347
+ />
348
+ </Animated.View>
349
+ ) : (
350
+ <>
198
351
  <Animated.View
199
352
  pointerEvents="none"
200
353
  style={[StyleSheet.absoluteFill, { boxShadow: paint.shadow }, shadowStyle]}
@@ -210,43 +363,71 @@ function PageHeaderComponent({
210
363
  style={[styles.border, { backgroundColor: paint.separator }, borderStyle]}
211
364
  testID={testID ? `${testID}-border` : undefined}
212
365
  />
366
+ </>
367
+ );
368
+
369
+ const actionsSlot = actions ? (
370
+ <View
371
+ style={[styles.actions, { gap: floating ? FLOATING_ACTIONS_GAP : ACTIONS_GAP }]}
372
+ onLayout={centered ? onEndLayout : undefined}
373
+ // `box-none` so the gaps BETWEEN islands stay transparent to touch. A
374
+ // floating header covers content it does not own, and an invisible
375
+ // rectangle that swallows presses across the width of the screen is the
376
+ // defect this presentation could most easily ship.
377
+ //
378
+ // FLOATING ONLY, and the asymmetry is the point: a `bar` is an OPAQUE
379
+ // strip, and letting presses through the part of it that draws no control
380
+ // would hand a click on solid chrome to whatever list row is scrolled
381
+ // underneath.
382
+ pointerEvents={floating ? 'box-none' : undefined}
383
+ testID={testID ? `${testID}-actions` : undefined}
384
+ >
385
+ {floating ? <ControlSurface material="glass">{actions}</ControlSurface> : actions}
386
+ </View>
387
+ ) : null;
388
+
389
+ return (
390
+ <View
391
+ testID={testID}
392
+ onLayout={onContainerLayout}
393
+ pointerEvents={overlay ? 'box-none' : undefined}
394
+ style={[
395
+ styles.container,
396
+ { paddingTop: padTop },
397
+ overlay ? [styles.overlay, { zIndex: Z_INDEX.floating }] : null,
398
+ containerWeb,
399
+ style,
400
+ ]}
401
+ >
402
+ {chrome}
213
403
  <View
214
404
  style={[styles.bar, { paddingLeft: sideInset, paddingRight: sideInset }]}
405
+ pointerEvents={floating ? 'box-none' : undefined}
215
406
  testID={testID ? `${testID}-bar` : undefined}
216
407
  >
217
408
  <View
218
409
  style={[styles.start, !centered && styles.startFill]}
219
410
  onLayout={centered ? onStartLayout : undefined}
411
+ pointerEvents={floating ? 'box-none' : undefined}
220
412
  testID={testID ? `${testID}-start` : undefined}
221
413
  >
222
- {onBack ? (
223
- <Button
224
- variant="secondary"
225
- size="medium"
226
- iconOnly
227
- leadingIcon={RiArrowLeftLine}
228
- accessibilityLabel={backLabel}
229
- onPress={onBack}
230
- testID={testID ? `${testID}-back` : undefined}
231
- />
232
- ) : null}
233
- {leading}
414
+ {backButton}
415
+ {floating && leading ? (
416
+ <ControlSurface material="glass">{leading}</ControlSurface>
417
+ ) : (
418
+ leading
419
+ )}
234
420
  {centered ? null : titleBlock}
235
421
  </View>
236
- {centered ? <View style={styles.spacer} /> : null}
237
- {actions ? (
238
- <View
239
- style={styles.actions}
240
- onLayout={centered ? onEndLayout : undefined}
241
- testID={testID ? `${testID}-actions` : undefined}
242
- >
243
- {actions}
244
- </View>
245
- ) : null}
422
+ {centered ? <View style={styles.spacer} pointerEvents="none" /> : null}
423
+ {actionsSlot}
246
424
  {centered && titleBlock ? (
247
425
  <View
248
426
  pointerEvents="box-none"
249
- style={[styles.centerSlot, { left: sideInset + centerInset, right: sideInset + centerInset }]}
427
+ style={[
428
+ styles.centerSlot,
429
+ { left: sideInset + centerInset, right: sideInset + centerInset },
430
+ ]}
250
431
  testID={testID ? `${testID}-center` : undefined}
251
432
  >
252
433
  {titleBlock}
@@ -262,6 +443,18 @@ const styles = StyleSheet.create({
262
443
  width: '100%',
263
444
  position: 'relative',
264
445
  },
446
+ overlay: {
447
+ position: 'absolute',
448
+ top: 0,
449
+ left: 0,
450
+ right: 0,
451
+ },
452
+ scrim: {
453
+ position: 'absolute',
454
+ top: 0,
455
+ left: 0,
456
+ right: 0,
457
+ },
265
458
  border: {
266
459
  position: 'absolute',
267
460
  left: 0,
@@ -310,7 +503,6 @@ const styles = StyleSheet.create({
310
503
  flexDirection: 'row',
311
504
  alignItems: 'center',
312
505
  justifyContent: 'flex-end',
313
- gap: ACTIONS_GAP,
314
506
  flexShrink: 0,
315
507
  },
316
508
  });
@@ -1,3 +1,11 @@
1
1
  export { PageHeader } from './PageHeader';
2
2
 
3
- export type { PageHeaderBorder, PageHeaderProps, PageHeaderTitleAlign } from './types';
3
+ export type {
4
+ PageHeaderBorder,
5
+ PageHeaderPlacement,
6
+ PageHeaderPresentation,
7
+ PageHeaderProps,
8
+ PageHeaderScrim,
9
+ PageHeaderTitleAlign,
10
+ PageHeaderTitleReveal,
11
+ } from './types';
@@ -5,12 +5,69 @@ import type { SharedValue } from 'react-native-reanimated';
5
5
  /** Where the title sits: after the back button, or centred on the bar. */
6
6
  export type PageHeaderTitleAlign = 'start' | 'center';
7
7
 
8
+ /**
9
+ * How the header presents itself.
10
+ *
11
+ * `floating` is the default: no bar at all. The back button sits in its own
12
+ * translucent capsule, related actions share one island, the title floats
13
+ * between them, and the whole thing ends in a soft vertical gradient instead of
14
+ * a horizontal edge. Content passes UNDER it.
15
+ *
16
+ * `bar` is Bloom's original flat header — an opaque full-width strip with a
17
+ * hairline separator and a scroll-linked shadow. It is still the right shape
18
+ * for a dense tool screen, a split-pane sidebar header or anywhere the chrome
19
+ * must not be mistaken for content; it is no longer the default.
20
+ */
21
+ export type PageHeaderPresentation = 'floating' | 'bar';
22
+
23
+ /**
24
+ * Who reserves the space the header occupies.
25
+ *
26
+ * `inline` (default): the header is a sibling ABOVE the content, in a column.
27
+ * It occupies layout, so nothing passes under it and nothing needs padding.
28
+ *
29
+ * `overlay`: the header is absolutely positioned over the content, which starts
30
+ * at the top of the container and scrolls under the islands. The header then
31
+ * CLAIMS its measured height on `layout/top-edge`, and the content reads it
32
+ * with `useTopEdgeInset()` so its first item is not born underneath. This is
33
+ * the placement the floating presentation was designed for; the two are
34
+ * separate props because a floating header above a fixed panel is also a real
35
+ * arrangement.
36
+ */
37
+ export type PageHeaderPlacement = 'inline' | 'overlay';
38
+
8
39
  /**
9
40
  * The bottom separator: `auto` fades it in once the page has scrolled past
10
41
  * `scrollThreshold`, `always` draws it at rest, `none` never draws it.
42
+ *
43
+ * `presentation="bar"` only. A floating header has no edge to separate.
11
44
  */
12
45
  export type PageHeaderBorder = 'auto' | 'always' | 'none';
13
46
 
47
+ /**
48
+ * The floating header's edge effect: a vertical gradient from the page colour
49
+ * to full transparency, behind the title and the islands.
50
+ *
51
+ * `auto` (default) fades it in over `[0, scrollThreshold]` of the scroll
52
+ * offset, so it appears exactly when content starts passing under it. `always`
53
+ * paints it at rest — for a header that is born over a photograph. `none`
54
+ * removes it, which leaves the islands to carry legibility on their own.
55
+ */
56
+ export type PageHeaderScrim = 'auto' | 'always' | 'none';
57
+
58
+ /**
59
+ * When the title is visible.
60
+ *
61
+ * `always` (default) is the ordinary screen header. `onScroll` holds the title
62
+ * back and fades it in with the scrim — the shape a header over a hero photo
63
+ * wants, where the photo already names the screen.
64
+ *
65
+ * It is a prop of its own rather than a side effect of the background, because
66
+ * the two are different questions and conflating them is how every header that
67
+ * turned transparent also lost its title.
68
+ */
69
+ export type PageHeaderTitleReveal = 'always' | 'onScroll';
70
+
14
71
  export interface PageHeaderProps {
15
72
  /** A string renders as the heading; a node renders as-is (and owns its own semantics). */
16
73
  title?: ReactNode;
@@ -20,6 +77,13 @@ export interface PageHeaderProps {
20
77
  titleAlign?: PageHeaderTitleAlign;
21
78
  /** The title's heading level. Default 1. */
22
79
  headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
80
+ /** Default `always`. */
81
+ titleReveal?: PageHeaderTitleReveal;
82
+
83
+ /** Default `floating`. */
84
+ presentation?: PageHeaderPresentation;
85
+ /** Default `inline`. */
86
+ placement?: PageHeaderPlacement;
23
87
 
24
88
  /**
25
89
  * Renders the back button and is called when it is pressed. The header is
@@ -31,25 +95,54 @@ export interface PageHeaderProps {
31
95
 
32
96
  /** Rendered after the back button, before the title (an avatar, a logo). */
33
97
  leading?: ReactNode;
34
- /** Rendered at the end of the bar, 10 apart — typically Bloom `Button`s. */
98
+ /**
99
+ * Rendered at the end of the bar. In the floating presentation this slot is
100
+ * wrapped in a `ControlSurface` with `material: 'glass'`, so a `ButtonGroup`
101
+ * inside it becomes one island with no prop written on it. Declare the
102
+ * grouping yourself — the header never infers it by reading children.
103
+ */
35
104
  actions?: ReactNode;
36
105
 
37
- /** Default `auto`. */
106
+ /** `presentation="bar"` only. Default `auto`. */
38
107
  border?: PageHeaderBorder;
39
108
  /**
40
- * The bar's background, shadow and title fade in with scroll instead of
41
- * showing at rest — for a header over media. The back button and actions
42
- * stay visible (their own fill reads over any image).
109
+ * `presentation="bar"` only: the bar's background and shadow fade in with
110
+ * scroll instead of showing at rest.
111
+ *
112
+ * It no longer hides the title — that is `titleReveal="onScroll"`, and the
113
+ * two are set independently. A `bar` header that wants the old combined
114
+ * behaviour asks for both.
43
115
  */
44
116
  transparent?: boolean;
117
+ /** `presentation="floating"` only. Default `auto`. */
118
+ scrim?: PageHeaderScrim;
119
+ /**
120
+ * The colour the edge effect fades FROM. Defaults to `theme.colors.background`
121
+ * — the page colour, which is what the content scrolling under the header is
122
+ * disappearing into.
123
+ *
124
+ * Pass it when the screen paints a background the theme does not know about:
125
+ * a tinted section, a brand panel, a dark surface inside a light app. The
126
+ * header cannot read the colour behind it, and a scrim in the theme's
127
+ * background over a page in some other colour is a wash of the wrong hue
128
+ * rather than a fade.
129
+ *
130
+ * It must be OPAQUE. The alpha is the ramp's, and a colour that brings its
131
+ * own would multiply with it — `rgba(0,0,0,0.5)` at the ramp's 0.86 paints
132
+ * 0.43 and reads as a weak, dirty veil rather than the colour you asked for.
133
+ */
134
+ scrimColor?: string;
135
+
45
136
  /**
46
- * The scroll offset driving `border="auto"`, `transparent` and the shadow.
47
- * Pass it from a Reanimated scroll handler on native or for any scroll
48
- * container. Omitted on web, the header follows `window.scrollY`; omitted on
49
- * native, the header stays at rest.
137
+ * The scroll offset driving the scrim, the separator and the shadow.
138
+ *
139
+ * Resolution order: this prop, then the nearest `ScrollOffsetProvider`
140
+ * (`@oxy.so/bloom/layout`) — which is how a header inside a Bloom scroll
141
+ * composition gets it with no wiring — then, on web only, `window.scrollY`.
142
+ * On native with none of the three, the header stays at rest.
50
143
  */
51
144
  scrollY?: SharedValue<number>;
52
- /** The offset (px) over which border, background and shadow fade in. Default 20. */
145
+ /** The offset (px) over which the scrim, separator and shadow fade in. Default 20. */
53
146
  scrollThreshold?: number;
54
147
 
55
148
  /** Web only: pin the bar to the top of its scroll container. Default true. */
@@ -35,6 +35,7 @@ import type { ReactNode } from 'react';
35
35
 
36
36
  import { GlassBlurTargetProvider } from '../glass/blur-target';
37
37
  import { BottomEdgeProvider } from '../layout/bottom-edge';
38
+ import { TopEdgeProvider } from '../layout/top-edge';
38
39
  import { ImageResolverProvider, type ImageResolver } from '../image-resolver';
39
40
  import { BloomHapticsProvider } from '../hooks/use-haptics';
40
41
  import { TabBarMinimizeProvider } from '../tab-bar/context';
@@ -76,7 +77,17 @@ export function BloomProvider({
76
77
  the other.
77
78
  */}
78
79
  <BottomEdgeProvider>
79
- <TabBarMinimizeProvider>{children}</TabBarMinimizeProvider>
80
+ {/*
81
+ And the top edge's, for the same reason one edge further up: a
82
+ floating `PageHeader` overlays the content rather than sitting
83
+ above it, so the content has to be told how much of the edge is
84
+ gone. Two registries rather than one keyed by edge, because a
85
+ reader of either only ever wants one number and must not
86
+ re-render when the other moves.
87
+ */}
88
+ <TopEdgeProvider>
89
+ <TabBarMinimizeProvider>{children}</TabBarMinimizeProvider>
90
+ </TopEdgeProvider>
80
91
  </BottomEdgeProvider>
81
92
  </GlassBlurTargetProvider>
82
93
  </BloomHapticsProvider>
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import Svg, { ClipPath, Defs, G, Path } from 'react-native-svg';
3
3
 
4
- import { useSvgIdPrefix } from './use-svg-id';
4
+ import { useSvgIdPrefix } from '../styles/svg-id';
5
5
 
6
6
  /**
7
7
  * The Oxy mark for `SocialButton` — the same drawing the Oxy SDK ships.
@@ -20,7 +20,7 @@ import { StyledPressable } from '../styles/styled-primitives';
20
20
  import type { WebCssStyle } from '../styles/web-view-style';
21
21
  import { SOCIAL_COLOR_LOGOS } from './color-logos';
22
22
  import { OxyMark } from './OxyMark';
23
- import { useSvgIdPrefix } from './use-svg-id';
23
+ import { useSvgIdPrefix } from '../styles/svg-id';
24
24
  import { SOCIAL_PROVIDERS, type SocialColorLogo, type SocialProvider } from './providers';
25
25
  import type {
26
26
  SocialBrand,
@@ -0,0 +1,23 @@
1
+ import { useId } from 'react';
2
+
3
+ /**
4
+ * A DOM-safe id prefix, per component instance.
5
+ *
6
+ * Two SVGs of one shape on a page must not resolve each other's `url(#…)`, and
7
+ * React's `useId` carries characters an SVG id cannot (`:r0:`). Stripping them
8
+ * keeps the id UNIQUE — `useId` is unique per instance before the strip and the
9
+ * strip is injective over React's own alphabet — while staying a legal XML name.
10
+ *
11
+ * It is `useId` rather than a module-scope counter for one reason a counter
12
+ * cannot give: SSR. A counter increments in a different order on the server than
13
+ * during hydration, so the markup React sends and the markup it re-renders
14
+ * reference different gradients and one of them is not there. `useId` is the
15
+ * hook React added for exactly this and is stable across the two passes.
16
+ *
17
+ * Lives in `styles/` rather than beside its first caller because it now has
18
+ * three (`social-button`'s mark and button, `page-header`'s edge gradient) —
19
+ * the second real need is the bar for extracting it, and this cleared it.
20
+ */
21
+ export function useSvgIdPrefix(prefix: string): string {
22
+ return `${prefix}-${useId().replace(/[^a-zA-Z0-9]/g, '')}-`;
23
+ }