@oxy.so/bloom 2.12.3 → 3.0.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 (306) 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 +46 -0
  6. package/docs/layout.mdx +72 -4
  7. package/docs/message-bubble.mdx +37 -3
  8. package/docs/message-media.mdx +6 -0
  9. package/docs/migrating-to-3.0.mdx +169 -0
  10. package/docs/page-header.mdx +192 -36
  11. package/lib/commonjs/button/Button.js +2 -0
  12. package/lib/commonjs/button/Button.js.map +1 -1
  13. package/lib/commonjs/button-group/ButtonGroup.js +124 -19
  14. package/lib/commonjs/button-group/ButtonGroup.js.map +1 -1
  15. package/lib/commonjs/control-surface/ControlSurface.js +46 -0
  16. package/lib/commonjs/control-surface/ControlSurface.js.map +1 -0
  17. package/lib/commonjs/control-surface/context.js +84 -0
  18. package/lib/commonjs/control-surface/context.js.map +1 -0
  19. package/lib/commonjs/control-surface/index.js +26 -0
  20. package/lib/commonjs/control-surface/index.js.map +1 -0
  21. package/lib/commonjs/control-surface/types.js +6 -0
  22. package/lib/commonjs/control-surface/types.js.map +1 -0
  23. package/lib/commonjs/field/Field.js +71 -26
  24. package/lib/commonjs/field/Field.js.map +1 -1
  25. package/lib/commonjs/field/context.js +80 -0
  26. package/lib/commonjs/field/context.js.map +1 -0
  27. package/lib/commonjs/field/index.js +7 -0
  28. package/lib/commonjs/field/index.js.map +1 -1
  29. package/lib/commonjs/floating/TriggerSlot.js +32 -0
  30. package/lib/commonjs/floating/TriggerSlot.js.map +1 -1
  31. package/lib/commonjs/glass/GlassIsland.js +107 -0
  32. package/lib/commonjs/glass/GlassIsland.js.map +1 -0
  33. package/lib/commonjs/glass/GlassSurface.js +4 -1
  34. package/lib/commonjs/glass/GlassSurface.js.map +1 -1
  35. package/lib/commonjs/glass/index.js +7 -0
  36. package/lib/commonjs/glass/index.js.map +1 -1
  37. package/lib/commonjs/index.js +564 -517
  38. package/lib/commonjs/index.js.map +1 -1
  39. package/lib/commonjs/index.web.js +564 -517
  40. package/lib/commonjs/index.web.js.map +1 -1
  41. package/lib/commonjs/label/Label.js +4 -1
  42. package/lib/commonjs/label/Label.js.map +1 -1
  43. package/lib/commonjs/layout/bottom-edge.js +3 -42
  44. package/lib/commonjs/layout/bottom-edge.js.map +1 -1
  45. package/lib/commonjs/layout/edge-store.js +65 -0
  46. package/lib/commonjs/layout/edge-store.js.map +1 -0
  47. package/lib/commonjs/layout/index.js +32 -0
  48. package/lib/commonjs/layout/index.js.map +1 -1
  49. package/lib/commonjs/layout/scroll-offset.js +83 -0
  50. package/lib/commonjs/layout/scroll-offset.js.map +1 -0
  51. package/lib/commonjs/layout/top-edge.js +109 -0
  52. package/lib/commonjs/layout/top-edge.js.map +1 -0
  53. package/lib/commonjs/message-bubble/MessageBubble.js +16 -5
  54. package/lib/commonjs/message-bubble/MessageBubble.js.map +1 -1
  55. package/lib/commonjs/page-header/EdgeScrim.js +136 -0
  56. package/lib/commonjs/page-header/EdgeScrim.js.map +1 -0
  57. package/lib/commonjs/page-header/PageHeader.js +236 -61
  58. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  59. package/lib/commonjs/provider/BloomProvider.js +5 -2
  60. package/lib/commonjs/provider/BloomProvider.js.map +1 -1
  61. package/lib/commonjs/social-button/OxyMark.js +2 -2
  62. package/lib/commonjs/social-button/OxyMark.js.map +1 -1
  63. package/lib/commonjs/social-button/SocialButton.js +2 -2
  64. package/lib/commonjs/social-button/SocialButton.js.map +1 -1
  65. package/lib/commonjs/styles/svg-id.js +29 -0
  66. package/lib/commonjs/styles/svg-id.js.map +1 -0
  67. package/lib/commonjs/text-field/TextField.js +39 -6
  68. package/lib/commonjs/text-field/TextField.js.map +1 -1
  69. package/lib/commonjs/theme/glass-colors.js +81 -1
  70. package/lib/commonjs/theme/glass-colors.js.map +1 -1
  71. package/lib/module/button/Button.js +2 -0
  72. package/lib/module/button/Button.js.map +1 -1
  73. package/lib/module/button-group/ButtonGroup.js +124 -19
  74. package/lib/module/button-group/ButtonGroup.js.map +1 -1
  75. package/lib/module/control-surface/ControlSurface.js +41 -0
  76. package/lib/module/control-surface/ControlSurface.js.map +1 -0
  77. package/lib/module/control-surface/context.js +77 -0
  78. package/lib/module/control-surface/context.js.map +1 -0
  79. package/lib/module/control-surface/index.js +5 -0
  80. package/lib/module/control-surface/index.js.map +1 -0
  81. package/lib/module/control-surface/types.js +4 -0
  82. package/lib/module/control-surface/types.js.map +1 -0
  83. package/lib/module/field/Field.js +72 -27
  84. package/lib/module/field/Field.js.map +1 -1
  85. package/lib/module/field/context.js +74 -0
  86. package/lib/module/field/context.js.map +1 -0
  87. package/lib/module/field/index.js +1 -0
  88. package/lib/module/field/index.js.map +1 -1
  89. package/lib/module/floating/TriggerSlot.js +33 -1
  90. package/lib/module/floating/TriggerSlot.js.map +1 -1
  91. package/lib/module/glass/GlassIsland.js +102 -0
  92. package/lib/module/glass/GlassIsland.js.map +1 -0
  93. package/lib/module/glass/GlassSurface.js +5 -2
  94. package/lib/module/glass/GlassSurface.js.map +1 -1
  95. package/lib/module/glass/index.js +1 -0
  96. package/lib/module/glass/index.js.map +1 -1
  97. package/lib/module/index.js +2 -1
  98. package/lib/module/index.js.map +1 -1
  99. package/lib/module/index.web.js +2 -1
  100. package/lib/module/index.web.js.map +1 -1
  101. package/lib/module/label/Label.js +4 -1
  102. package/lib/module/label/Label.js.map +1 -1
  103. package/lib/module/layout/bottom-edge.js +2 -41
  104. package/lib/module/layout/bottom-edge.js.map +1 -1
  105. package/lib/module/layout/edge-store.js +58 -0
  106. package/lib/module/layout/edge-store.js.map +1 -0
  107. package/lib/module/layout/index.js +8 -3
  108. package/lib/module/layout/index.js.map +1 -1
  109. package/lib/module/layout/scroll-offset.js +77 -0
  110. package/lib/module/layout/scroll-offset.js.map +1 -0
  111. package/lib/module/layout/top-edge.js +102 -0
  112. package/lib/module/layout/top-edge.js.map +1 -0
  113. package/lib/module/message-bubble/MessageBubble.js +16 -5
  114. package/lib/module/message-bubble/MessageBubble.js.map +1 -1
  115. package/lib/module/page-header/EdgeScrim.js +132 -0
  116. package/lib/module/page-header/EdgeScrim.js.map +1 -0
  117. package/lib/module/page-header/PageHeader.js +233 -58
  118. package/lib/module/page-header/PageHeader.js.map +1 -1
  119. package/lib/module/provider/BloomProvider.js +5 -2
  120. package/lib/module/provider/BloomProvider.js.map +1 -1
  121. package/lib/module/social-button/OxyMark.js +1 -1
  122. package/lib/module/social-button/OxyMark.js.map +1 -1
  123. package/lib/module/social-button/SocialButton.js +1 -1
  124. package/lib/module/social-button/SocialButton.js.map +1 -1
  125. package/lib/module/styles/svg-id.js +26 -0
  126. package/lib/module/styles/svg-id.js.map +1 -0
  127. package/lib/module/text-field/TextField.js +39 -6
  128. package/lib/module/text-field/TextField.js.map +1 -1
  129. package/lib/module/theme/glass-colors.js +79 -0
  130. package/lib/module/theme/glass-colors.js.map +1 -1
  131. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/button-group/ButtonGroup.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/button-group/types.d.ts +61 -5
  134. package/lib/typescript/commonjs/button-group/types.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts +4 -0
  136. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/control-surface/context.d.ts +21 -0
  138. package/lib/typescript/commonjs/control-surface/context.d.ts.map +1 -0
  139. package/lib/typescript/commonjs/control-surface/index.d.ts +4 -0
  140. package/lib/typescript/commonjs/control-surface/index.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/control-surface/types.d.ts +37 -0
  142. package/lib/typescript/commonjs/control-surface/types.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/field/Field.d.ts +1 -1
  144. package/lib/typescript/commonjs/field/Field.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/field/context.d.ts +55 -0
  146. package/lib/typescript/commonjs/field/context.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/field/index.d.ts +2 -0
  148. package/lib/typescript/commonjs/field/index.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/field/types.d.ts +22 -3
  150. package/lib/typescript/commonjs/field/types.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/floating/TriggerSlot.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/glass/GlassIsland.d.ts +4 -0
  153. package/lib/typescript/commonjs/glass/GlassIsland.d.ts.map +1 -0
  154. package/lib/typescript/commonjs/glass/GlassSurface.d.ts.map +1 -1
  155. package/lib/typescript/commonjs/glass/index.d.ts +2 -1
  156. package/lib/typescript/commonjs/glass/index.d.ts.map +1 -1
  157. package/lib/typescript/commonjs/glass/types.d.ts +43 -0
  158. package/lib/typescript/commonjs/glass/types.d.ts.map +1 -1
  159. package/lib/typescript/commonjs/index.d.ts +3 -2
  160. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/index.web.d.ts +3 -2
  162. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  163. package/lib/typescript/commonjs/label/Label.d.ts.map +1 -1
  164. package/lib/typescript/commonjs/label/types.d.ts +7 -1
  165. package/lib/typescript/commonjs/label/types.d.ts.map +1 -1
  166. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  167. package/lib/typescript/commonjs/layout/edge-store.d.ts +31 -0
  168. package/lib/typescript/commonjs/layout/edge-store.d.ts.map +1 -0
  169. package/lib/typescript/commonjs/layout/index.d.ts +8 -3
  170. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  171. package/lib/typescript/commonjs/layout/scroll-offset.d.ts +16 -0
  172. package/lib/typescript/commonjs/layout/scroll-offset.d.ts.map +1 -0
  173. package/lib/typescript/commonjs/layout/top-edge.d.ts +77 -0
  174. package/lib/typescript/commonjs/layout/top-edge.d.ts.map +1 -0
  175. package/lib/typescript/commonjs/message-bubble/MessageBubble.d.ts +1 -1
  176. package/lib/typescript/commonjs/message-bubble/MessageBubble.d.ts.map +1 -1
  177. package/lib/typescript/commonjs/message-bubble/index.d.ts +1 -1
  178. package/lib/typescript/commonjs/message-bubble/index.d.ts.map +1 -1
  179. package/lib/typescript/commonjs/message-bubble/types.d.ts +27 -5
  180. package/lib/typescript/commonjs/message-bubble/types.d.ts.map +1 -1
  181. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts +95 -0
  182. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts.map +1 -0
  183. package/lib/typescript/commonjs/page-header/PageHeader.d.ts +1 -1
  184. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  185. package/lib/typescript/commonjs/page-header/index.d.ts +1 -1
  186. package/lib/typescript/commonjs/page-header/index.d.ts.map +1 -1
  187. package/lib/typescript/commonjs/page-header/types.d.ts +97 -10
  188. package/lib/typescript/commonjs/page-header/types.d.ts.map +1 -1
  189. package/lib/typescript/commonjs/provider/BloomProvider.d.ts.map +1 -1
  190. package/lib/typescript/commonjs/styles/svg-id.d.ts +20 -0
  191. package/lib/typescript/commonjs/styles/svg-id.d.ts.map +1 -0
  192. package/lib/typescript/commonjs/text-field/TextField.d.ts.map +1 -1
  193. package/lib/typescript/commonjs/theme/glass-colors.d.ts +67 -0
  194. package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
  195. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  196. package/lib/typescript/module/button-group/ButtonGroup.d.ts.map +1 -1
  197. package/lib/typescript/module/button-group/types.d.ts +61 -5
  198. package/lib/typescript/module/button-group/types.d.ts.map +1 -1
  199. package/lib/typescript/module/control-surface/ControlSurface.d.ts +4 -0
  200. package/lib/typescript/module/control-surface/ControlSurface.d.ts.map +1 -0
  201. package/lib/typescript/module/control-surface/context.d.ts +21 -0
  202. package/lib/typescript/module/control-surface/context.d.ts.map +1 -0
  203. package/lib/typescript/module/control-surface/index.d.ts +4 -0
  204. package/lib/typescript/module/control-surface/index.d.ts.map +1 -0
  205. package/lib/typescript/module/control-surface/types.d.ts +37 -0
  206. package/lib/typescript/module/control-surface/types.d.ts.map +1 -0
  207. package/lib/typescript/module/field/Field.d.ts +1 -1
  208. package/lib/typescript/module/field/Field.d.ts.map +1 -1
  209. package/lib/typescript/module/field/context.d.ts +55 -0
  210. package/lib/typescript/module/field/context.d.ts.map +1 -0
  211. package/lib/typescript/module/field/index.d.ts +2 -0
  212. package/lib/typescript/module/field/index.d.ts.map +1 -1
  213. package/lib/typescript/module/field/types.d.ts +22 -3
  214. package/lib/typescript/module/field/types.d.ts.map +1 -1
  215. package/lib/typescript/module/floating/TriggerSlot.d.ts.map +1 -1
  216. package/lib/typescript/module/glass/GlassIsland.d.ts +4 -0
  217. package/lib/typescript/module/glass/GlassIsland.d.ts.map +1 -0
  218. package/lib/typescript/module/glass/GlassSurface.d.ts.map +1 -1
  219. package/lib/typescript/module/glass/index.d.ts +2 -1
  220. package/lib/typescript/module/glass/index.d.ts.map +1 -1
  221. package/lib/typescript/module/glass/types.d.ts +43 -0
  222. package/lib/typescript/module/glass/types.d.ts.map +1 -1
  223. package/lib/typescript/module/index.d.ts +3 -2
  224. package/lib/typescript/module/index.d.ts.map +1 -1
  225. package/lib/typescript/module/index.web.d.ts +3 -2
  226. package/lib/typescript/module/index.web.d.ts.map +1 -1
  227. package/lib/typescript/module/label/Label.d.ts.map +1 -1
  228. package/lib/typescript/module/label/types.d.ts +7 -1
  229. package/lib/typescript/module/label/types.d.ts.map +1 -1
  230. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  231. package/lib/typescript/module/layout/edge-store.d.ts +31 -0
  232. package/lib/typescript/module/layout/edge-store.d.ts.map +1 -0
  233. package/lib/typescript/module/layout/index.d.ts +8 -3
  234. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  235. package/lib/typescript/module/layout/scroll-offset.d.ts +16 -0
  236. package/lib/typescript/module/layout/scroll-offset.d.ts.map +1 -0
  237. package/lib/typescript/module/layout/top-edge.d.ts +77 -0
  238. package/lib/typescript/module/layout/top-edge.d.ts.map +1 -0
  239. package/lib/typescript/module/message-bubble/MessageBubble.d.ts +1 -1
  240. package/lib/typescript/module/message-bubble/MessageBubble.d.ts.map +1 -1
  241. package/lib/typescript/module/message-bubble/index.d.ts +1 -1
  242. package/lib/typescript/module/message-bubble/index.d.ts.map +1 -1
  243. package/lib/typescript/module/message-bubble/types.d.ts +27 -5
  244. package/lib/typescript/module/message-bubble/types.d.ts.map +1 -1
  245. package/lib/typescript/module/page-header/EdgeScrim.d.ts +95 -0
  246. package/lib/typescript/module/page-header/EdgeScrim.d.ts.map +1 -0
  247. package/lib/typescript/module/page-header/PageHeader.d.ts +1 -1
  248. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  249. package/lib/typescript/module/page-header/index.d.ts +1 -1
  250. package/lib/typescript/module/page-header/index.d.ts.map +1 -1
  251. package/lib/typescript/module/page-header/types.d.ts +97 -10
  252. package/lib/typescript/module/page-header/types.d.ts.map +1 -1
  253. package/lib/typescript/module/provider/BloomProvider.d.ts.map +1 -1
  254. package/lib/typescript/module/styles/svg-id.d.ts +20 -0
  255. package/lib/typescript/module/styles/svg-id.d.ts.map +1 -0
  256. package/lib/typescript/module/text-field/TextField.d.ts.map +1 -1
  257. package/lib/typescript/module/theme/glass-colors.d.ts +67 -0
  258. package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
  259. package/package.json +15 -1
  260. package/src/button/Button.tsx +10 -1
  261. package/src/button-group/ButtonGroup.tsx +146 -19
  262. package/src/button-group/types.ts +68 -5
  263. package/src/control-surface/ControlSurface.tsx +41 -0
  264. package/src/control-surface/context.ts +81 -0
  265. package/src/control-surface/index.ts +4 -0
  266. package/src/control-surface/types.ts +40 -0
  267. package/src/field/Field.tsx +84 -27
  268. package/src/field/context.ts +110 -0
  269. package/src/field/index.ts +3 -0
  270. package/src/field/types.ts +22 -3
  271. package/src/floating/TriggerSlot.tsx +35 -1
  272. package/src/glass/GlassIsland.tsx +109 -0
  273. package/src/glass/GlassSurface.tsx +26 -2
  274. package/src/glass/index.ts +3 -0
  275. package/src/glass/types.ts +45 -0
  276. package/src/index.ts +7 -0
  277. package/src/index.web.ts +7 -0
  278. package/src/label/Label.tsx +3 -1
  279. package/src/label/types.ts +7 -1
  280. package/src/layout/bottom-edge.tsx +3 -56
  281. package/src/layout/edge-store.ts +69 -0
  282. package/src/layout/index.ts +8 -3
  283. package/src/layout/scroll-offset.ts +76 -0
  284. package/src/layout/top-edge.tsx +109 -0
  285. package/src/message-bubble/MessageBubble.tsx +19 -5
  286. package/src/message-bubble/index.ts +1 -0
  287. package/src/message-bubble/types.ts +28 -5
  288. package/src/page-header/EdgeScrim.tsx +118 -0
  289. package/src/page-header/PageHeader.tsx +253 -61
  290. package/src/page-header/index.ts +9 -1
  291. package/src/page-header/types.ts +103 -10
  292. package/src/provider/BloomProvider.tsx +12 -1
  293. package/src/social-button/OxyMark.tsx +1 -1
  294. package/src/social-button/SocialButton.tsx +1 -1
  295. package/src/styles/svg-id.ts +23 -0
  296. package/src/text-field/TextField.tsx +44 -5
  297. package/src/theme/glass-colors.ts +80 -0
  298. package/lib/commonjs/social-button/use-svg-id.js +0 -16
  299. package/lib/commonjs/social-button/use-svg-id.js.map +0 -1
  300. package/lib/module/social-button/use-svg-id.js +0 -13
  301. package/lib/module/social-button/use-svg-id.js.map +0 -1
  302. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts +0 -7
  303. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts.map +0 -1
  304. package/lib/typescript/module/social-button/use-svg-id.d.ts +0 -7
  305. package/lib/typescript/module/social-button/use-svg-id.d.ts.map +0 -1
  306. package/src/social-button/use-svg-id.ts +0 -10
@@ -40,8 +40,10 @@ const LabelComponent = function Label({
40
40
  }: LabelProps) {
41
41
  const palette = useTextFieldPalette();
42
42
 
43
+ // `null` is DISTINCT from omitted: omitted falls back to the control's id,
44
+ // `null` says the label names a group and points at nothing.
43
45
  const webProps: Record<string, unknown> =
44
- IS_WEB ? { htmlFor: htmlFor ?? nativeID } : {};
46
+ IS_WEB ? { htmlFor: htmlFor === null ? undefined : (htmlFor ?? nativeID) } : {};
45
47
 
46
48
  return (
47
49
  <Text
@@ -11,8 +11,14 @@ export interface LabelProps {
11
11
  /**
12
12
  * Web-only convenience: the `id` of the form control this label is for.
13
13
  * Defaults to `nativeID` when omitted. Ignored on native.
14
+ *
15
+ * `null` means there is NO single control to point at — a label naming a
16
+ * GROUP of them, which is `Field`'s `multiple` case. Without a way to say
17
+ * that, the `nativeID` fallback makes the label its own target: a
18
+ * `<label for>` pointing at the label, which focuses nothing and which
19
+ * nothing reports.
14
20
  */
15
- htmlFor?: string;
21
+ htmlFor?: string | null;
16
22
  /**
17
23
  * Render a required marker (an asterisk in the theme's negative color)
18
24
  * after the label text. Defaults to `false`.
@@ -67,57 +67,9 @@ import {
67
67
  type PropsWithChildren,
68
68
  } from 'react';
69
69
 
70
- interface BottomEdgeStore {
71
- subscribe: (onChange: () => void) => () => void;
72
- /**
73
- * The cached total. Returns the SAME number until a claim actually changes it
74
- * — `useSyncExternalStore` re-renders forever if the snapshot is recomputed
75
- * per call.
76
- */
77
- getInset: () => number;
78
- claim: (id: string, height: number) => void;
79
- release: (id: string) => void;
80
- }
81
-
82
- function createBottomEdgeStore(): BottomEdgeStore {
83
- const claims = new Map<string, number>();
84
- const listeners = new Set<() => void>();
85
- let inset = 0;
86
-
87
- const recompute = () => {
88
- let next = 0;
89
- for (const height of claims.values()) {
90
- if (height > next) next = height;
91
- }
92
- // Bail before notifying: a re-registration at an unchanged height (every
93
- // render of a claimant whose footprint did not move) must not re-render
94
- // every reader.
95
- if (next === inset) return;
96
- inset = next;
97
- for (const listener of listeners) listener();
98
- };
70
+ import { createEdgeStore, NO_INSET, NO_SUBSCRIPTION, type EdgeStore } from './edge-store';
99
71
 
100
- return {
101
- subscribe(onChange) {
102
- listeners.add(onChange);
103
- return () => {
104
- listeners.delete(onChange);
105
- };
106
- },
107
- getInset: () => inset,
108
- claim(id, height) {
109
- if (claims.get(id) === height) return;
110
- claims.set(id, height);
111
- recompute();
112
- },
113
- release(id) {
114
- if (!claims.delete(id)) return;
115
- recompute();
116
- },
117
- };
118
- }
119
-
120
- const BottomEdgeContext = createContext<BottomEdgeStore | null>(null);
72
+ const BottomEdgeContext = createContext<EdgeStore | null>(null);
121
73
 
122
74
  /**
123
75
  * Wrap the app once — `BloomProvider` already does. Rendering a second one
@@ -126,17 +78,12 @@ const BottomEdgeContext = createContext<BottomEdgeStore | null>(null);
126
78
  * the tab bar the modal is covering.
127
79
  */
128
80
  export function BottomEdgeProvider({ children }: PropsWithChildren) {
129
- const [store] = useState(createBottomEdgeStore);
81
+ const [store] = useState(createEdgeStore);
130
82
  return <BottomEdgeContext.Provider value={store}>{children}</BottomEdgeContext.Provider>;
131
83
  }
132
84
 
133
85
  BottomEdgeProvider.displayName = 'BottomEdgeProvider';
134
86
 
135
- // Stable module-scope identities for the no-provider path. Fresh closures here
136
- // would resubscribe `useSyncExternalStore` on every render.
137
- const NO_SUBSCRIPTION = () => () => {};
138
- const NO_INSET = () => 0;
139
-
140
87
  /**
141
88
  * How much of the bottom edge is already occupied, in px.
142
89
  *
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The claim REGISTRY behind `bottom-edge` and `top-edge`, written once.
3
+ *
4
+ * `bottom-edge.tsx` carried this store inline while it was the only edge with
5
+ * claimants. A floating page header is the second — it parks at the TOP edge
6
+ * and the content under it has the same question the FAB had about the tab bar:
7
+ * how much of the edge is already taken. The two registries are the same object
8
+ * with a different context, so the object moved here rather than being written
9
+ * out twice; the reasoning that decided its shape (why MAX rather than sum, why
10
+ * an external store, why the collapse is NOT published through it) stays in
11
+ * `bottom-edge.tsx`, which is where a reader looking for it will be.
12
+ */
13
+
14
+ export interface EdgeStore {
15
+ subscribe: (onChange: () => void) => () => void;
16
+ /**
17
+ * The cached total. Returns the SAME number until a claim actually changes it
18
+ * — `useSyncExternalStore` re-renders forever if the snapshot is recomputed
19
+ * per call.
20
+ */
21
+ getInset: () => number;
22
+ claim: (id: string, height: number) => void;
23
+ release: (id: string) => void;
24
+ }
25
+
26
+ export function createEdgeStore(): EdgeStore {
27
+ const claims = new Map<string, number>();
28
+ const listeners = new Set<() => void>();
29
+ let inset = 0;
30
+
31
+ const recompute = () => {
32
+ let next = 0;
33
+ for (const height of claims.values()) {
34
+ if (height > next) next = height;
35
+ }
36
+ // Bail before notifying: a re-registration at an unchanged height (every
37
+ // render of a claimant whose footprint did not move) must not re-render
38
+ // every reader.
39
+ if (next === inset) return;
40
+ inset = next;
41
+ for (const listener of listeners) listener();
42
+ };
43
+
44
+ return {
45
+ subscribe(onChange) {
46
+ listeners.add(onChange);
47
+ return () => {
48
+ listeners.delete(onChange);
49
+ };
50
+ },
51
+ getInset: () => inset,
52
+ claim(id, height) {
53
+ if (claims.get(id) === height) return;
54
+ claims.set(id, height);
55
+ recompute();
56
+ },
57
+ release(id) {
58
+ if (!claims.delete(id)) return;
59
+ recompute();
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Stable module-scope identities for the no-provider path. Fresh closures here
66
+ * would resubscribe `useSyncExternalStore` on every render.
67
+ */
68
+ export const NO_SUBSCRIPTION = () => () => {};
69
+ export const NO_INSET = () => 0;
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Window-edge geometry, shared by every floating surface.
2
+ * Window-edge geometry and screen-scope coordination, shared by every floating
3
+ * surface and every piece of scroll-linked chrome.
3
4
  *
4
- * `edge` answers "how far from the edge"; `bottom-edge` answers "what is already
5
- * parked there". A surface that floats at the bottom needs both.
5
+ * `edge` answers "how far from the edge"; `bottom-edge` and `top-edge` answer
6
+ * "what is already parked there"; `scroll-offset` answers "which scroller am I
7
+ * following". A surface that floats at an edge needs the first two; chrome that
8
+ * reacts to scrolling needs the third.
6
9
  */
7
10
  export { EDGE_GAP, windowEdgeGap } from './edge';
8
11
  export { BottomEdgeProvider, useBottomEdgeInset, useClaimBottomEdge } from './bottom-edge';
12
+ export { TopEdgeProvider, useClaimTopEdge, useTopEdgeInset } from './top-edge';
13
+ export { ScrollOffsetProvider, useScrollOffset } from './scroll-offset';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * WHICH SCROLLER the chrome around a screen is following.
3
+ *
4
+ * ## The problem it answers
5
+ *
6
+ * Every piece of scroll-linked chrome Bloom draws — a header's separator, its
7
+ * shadow, the scrim under its islands — is a function of one number: how far
8
+ * one scroll container has moved. The container is the screen's, and the chrome
9
+ * is usually not inside it, so the number has to travel.
10
+ *
11
+ * Until now it travelled as a prop, and the default when the prop was omitted
12
+ * was `window.scrollY` on web and "nothing moves" on native. That default is
13
+ * right for exactly one layout — a web page whose document IS the scroller —
14
+ * and silently wrong for the two that a Bloom app usually has: a desktop panel
15
+ * that scrolls inside a fixed shell, and any native screen at all. The failure
16
+ * has no error in it. The header simply never reacts, which reads as a header
17
+ * that was designed not to.
18
+ *
19
+ * ## The rule
20
+ *
21
+ * A screen has exactly ONE scroll owner, and the owner publishes its offset
22
+ * here. Chrome reads it. Neither imports the other, so an app composes them in
23
+ * any arrangement — a header above a list, a header overlaying a list, two
24
+ * panels side by side each with its own header and its own scroller — and the
25
+ * right header still follows the right list, because the provider's position in
26
+ * the tree is what pairs them.
27
+ *
28
+ * ```tsx
29
+ * const scrollY = useSharedValue(0);
30
+ * const onScroll = useAnimatedScrollHandler(
31
+ * { onScroll: (e) => { scrollY.value = e.contentOffset.y; } },
32
+ * [scrollY],
33
+ * );
34
+ *
35
+ * <ScrollOffsetProvider value={scrollY}>
36
+ * <PageHeader title="Inbox" />
37
+ * <Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16}>…</Animated.ScrollView>
38
+ * </ScrollOffsetProvider>
39
+ * ```
40
+ *
41
+ * ## Why a SharedValue and not state
42
+ *
43
+ * The same reason `layout/bottom-edge.tsx` refuses to publish the tab bar's
44
+ * collapse: a scroll offset changes on every frame, and a React re-render per
45
+ * frame is not a performance detail here, it is the whole cost of the feature.
46
+ * A `SharedValue` crosses this context ONCE, at mount — the object identity
47
+ * never changes — and every subsequent frame reaches the chrome on the UI
48
+ * thread through `useAnimatedStyle`, with no render at all.
49
+ *
50
+ * That is also why the context holds the shared VALUE rather than a number: a
51
+ * context whose value changed per frame would re-render every consumer per
52
+ * frame, which is exactly what this exists to avoid.
53
+ */
54
+ import { createContext, useContext } from 'react';
55
+ import type { SharedValue } from 'react-native-reanimated';
56
+
57
+ const ScrollOffsetContext = createContext<SharedValue<number> | null>(null);
58
+ ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
59
+
60
+ /**
61
+ * Publish this subtree's scroll offset. `value` is a `SharedValue<number>` the
62
+ * scroll owner writes from its own animated handler.
63
+ */
64
+ export const ScrollOffsetProvider = ScrollOffsetContext.Provider;
65
+
66
+ /**
67
+ * The nearest published scroll offset, or `null` when nothing published one.
68
+ *
69
+ * `null` rather than a zero shared value, because the two mean different things
70
+ * to the chrome reading it: a published zero is "the scroller is at the top",
71
+ * and `null` is "no one told me", which is the case where a web header may
72
+ * still fall back to the document's own scroll.
73
+ */
74
+ export function useScrollOffset(): SharedValue<number> | null {
75
+ return useContext(ScrollOffsetContext);
76
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The top edge's OCCUPANCY — the mirror of `bottom-edge.tsx`, for chrome that
3
+ * parks at the TOP of a screen.
4
+ *
5
+ * ## Why a floating header needs it and a bar header does not
6
+ *
7
+ * A header that participates in layout answers this question by existing: the
8
+ * content starts where the header ends, because the header is a sibling above
9
+ * it in a column. A header that OVERLAYS content — the floating presentation,
10
+ * where the page scrolls under translucent islands — occupies no layout space
11
+ * at all, and the first item of the content lands underneath it.
12
+ *
13
+ * Somebody has to pad that content, and there are only three candidates. The
14
+ * app can hardcode a number, which is what every fleet header made its
15
+ * consumers do and which goes wrong the moment a subtitle, a safe-area inset or
16
+ * a larger font changes the height. The header can pad the content, which it
17
+ * cannot do — it does not own it, and often is not even its ancestor. Or the
18
+ * header can declare what it occupies and the content can read it. That is this
19
+ * registry, and it is the same object the bottom edge already uses
20
+ * (`edge-store.ts`), so a reader who knows one knows the other.
21
+ *
22
+ * ## The height is MEASURED, not computed
23
+ *
24
+ * `PageHeader` claims what `onLayout` reports, not a constant plus the insets.
25
+ * The two disagree in every case the constant was written to survive — a
26
+ * two-line title under an enlarged font, a subtitle that wraps in a long
27
+ * translation, a notch — and the failure of a computed height is silent: the
28
+ * content starts a few points under the islands and looks like a design
29
+ * decision.
30
+ *
31
+ * One consequence, stated rather than hidden: a claim registers in an EFFECT,
32
+ * so a reader mounted in the same commit sees `0` on its first render and the
33
+ * claimed number on the next. For a scroll container's `paddingTop` that is one
34
+ * frame of content sitting too high, before any scroll has happened.
35
+ *
36
+ * `PageHeader` narrows that frame rather than closing it: it claims its own
37
+ * resting height (the safe-area inset plus the 56pt bar) from the first commit
38
+ * and replaces it with the measured height once `onLayout` reports, so the one
39
+ * bad frame is off by whatever a subtitle or an enlarged font adds rather than
40
+ * by the whole header.
41
+ */
42
+ import {
43
+ createContext,
44
+ useContext,
45
+ useEffect,
46
+ useId,
47
+ useState,
48
+ useSyncExternalStore,
49
+ type PropsWithChildren,
50
+ } from 'react';
51
+
52
+ import { createEdgeStore, NO_INSET, NO_SUBSCRIPTION, type EdgeStore } from './edge-store';
53
+
54
+ const TopEdgeContext = createContext<EdgeStore | null>(null);
55
+
56
+ /**
57
+ * Wrap the app once — `BloomProvider` already does. A nested provider scopes a
58
+ * subtree to its own claims, which is what a full-screen modal with its own
59
+ * header wants: the content inside it must clear the MODAL's header, not the
60
+ * screen's.
61
+ */
62
+ export function TopEdgeProvider({ children }: PropsWithChildren) {
63
+ const [store] = useState(createEdgeStore);
64
+ return <TopEdgeContext.Provider value={store}>{children}</TopEdgeContext.Provider>;
65
+ }
66
+
67
+ TopEdgeProvider.displayName = 'TopEdgeProvider';
68
+
69
+ /**
70
+ * How much of the top edge is already occupied, in px — the padding a scroll
71
+ * container needs so its first item is not born under the chrome.
72
+ *
73
+ * ```tsx
74
+ * <ScrollView contentContainerStyle={{ paddingTop: useTopEdgeInset() }}>
75
+ * ```
76
+ *
77
+ * `0` outside a provider, and `0` on the first render even inside one — a claim
78
+ * registers in an effect. See the module comment for what that costs and how
79
+ * `PageHeader` narrows it.
80
+ */
81
+ export function useTopEdgeInset(): number {
82
+ const store = useContext(TopEdgeContext);
83
+ return useSyncExternalStore(
84
+ store?.subscribe ?? NO_SUBSCRIPTION,
85
+ store?.getInset ?? NO_INSET,
86
+ store?.getInset ?? NO_INSET,
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Claim `height` px of the top edge for as long as the caller is mounted.
92
+ *
93
+ * Claiming does not move the claimant — it declares the space it already
94
+ * occupies so that everything reading `useTopEdgeInset()` starts below it. Pass
95
+ * the FULL footprint: the chrome's measured height including any safe-area
96
+ * padding it applied itself.
97
+ *
98
+ * A no-op outside a provider, so the chrome stays usable standalone.
99
+ */
100
+ export function useClaimTopEdge(height: number): void {
101
+ const store = useContext(TopEdgeContext);
102
+ const id = useId();
103
+
104
+ useEffect(() => {
105
+ if (!store) return;
106
+ store.claim(id, height);
107
+ return () => store.release(id);
108
+ }, [store, id, height]);
109
+ }
@@ -84,6 +84,15 @@ import type { MessageBubbleProps } from './types';
84
84
  *
85
85
  * Rich media is `media`, a slot: this family owns the shell, the text and the
86
86
  * metadata and never the image decoder.
87
+ *
88
+ * THE SLOT HAS TWO FITS, and the bubble cannot tell them apart by looking.
89
+ * `mediaFit="bleed"` (the default) cancels the bubble's padding so a photo runs
90
+ * to the radius. `mediaFit="inset"` keeps it, for a block that carries no
91
+ * padding of its own — `PollMessage`, `LocationMessage`, `ContactMessage` are
92
+ * all typography, and bled they clip a question against the top edge, cut an
93
+ * address off at the bottom and put an avatar on the left one. The fit also
94
+ * decides the meta: the overlaid pill is the default over a BLED media-only
95
+ * bubble only, because over an inset block it lands on a line of text.
87
96
  */
88
97
 
89
98
  /** How long the jump flash stays up before it fades. */
@@ -96,6 +105,7 @@ function MessageBubbleComponent({
96
105
  text,
97
106
  children,
98
107
  media,
108
+ mediaFit = 'bleed',
99
109
  metaOverlay,
100
110
  tail = false,
101
111
  senderName,
@@ -221,7 +231,8 @@ function MessageBubbleComponent({
221
231
  const fill = failed ? mixColor(side.fill, paint.failed, theme.isDark ? 0.3 : 0.22) : side.fill;
222
232
  const showTail = tail && hasTail(position);
223
233
  const mediaOnly = media !== undefined && text === undefined && children === undefined && !deleted;
224
- const overlayMeta = metaOverlay ?? mediaOnly;
234
+ const bleed = mediaFit === 'bleed';
235
+ const overlayMeta = metaOverlay ?? (mediaOnly && bleed);
225
236
  const hasMeta =
226
237
  !deleted &&
227
238
  (time !== undefined ||
@@ -287,10 +298,13 @@ function MessageBubbleComponent({
287
298
  {media === undefined || deleted ? null : (
288
299
  <View
289
300
  style={{
290
- marginTop: forwardedFrom === undefined && senderName === undefined && replyTo === undefined ? -BUBBLE_PADDING_Y : 0,
291
- marginLeft: -BUBBLE_PADDING_X,
292
- marginRight: -BUBBLE_PADDING_X,
293
- marginBottom: mediaOnly ? -BUBBLE_PADDING_Y : 2,
301
+ marginTop:
302
+ bleed && forwardedFrom === undefined && senderName === undefined && replyTo === undefined
303
+ ? -BUBBLE_PADDING_Y
304
+ : 0,
305
+ marginLeft: bleed ? -BUBBLE_PADDING_X : 0,
306
+ marginRight: bleed ? -BUBBLE_PADDING_X : 0,
307
+ marginBottom: bleed ? (mediaOnly ? -BUBBLE_PADDING_Y : 2) : 0,
294
308
  overflow: 'hidden',
295
309
  }}
296
310
  >
@@ -31,6 +31,7 @@ export type {
31
31
  MessageListEntry,
32
32
  MessageListItem,
33
33
  MessageListProps,
34
+ MessageMediaFit,
34
35
  MessagePosition,
35
36
  MessageReaction,
36
37
  MessageReplyPreview,
@@ -13,6 +13,21 @@ export type MessageDirection = 'incoming' | 'outgoing';
13
13
  */
14
14
  export type MessagePosition = 'single' | 'first' | 'middle' | 'last';
15
15
 
16
+ /**
17
+ * How the `media` slot is fitted inside the bubble.
18
+ *
19
+ * bleed the bubble's padding is CANCELLED and the block runs to the radius
20
+ * — a photo, a video, an album, a sticker: something with its own
21
+ * edge, which an inset would frame twice
22
+ * inset the block keeps the bubble's padding — a poll, a place, a contact
23
+ * card: typography with no padding of its own, which bled means a
24
+ * question clipped against the top edge and an avatar on the left one
25
+ *
26
+ * It also decides where the meta row lands by default: over a bled block on a
27
+ * translucent pill, under an inset one where it cannot cover a line of text.
28
+ */
29
+ export type MessageMediaFit = 'bleed' | 'inset';
30
+
16
31
  /** A text span the bubble hands back to the caller to render. */
17
32
  export type MessageEntityType = 'link' | 'mention' | 'hashtag';
18
33
 
@@ -76,15 +91,23 @@ export interface MessageBubbleProps {
76
91
  /** Arbitrary bubble content, in place of `text`. */
77
92
  children?: ReactNode;
78
93
  /**
79
- * The rich-media slot — an image, a voice note, a file row. It is rendered
80
- * ABOVE the text, edge to edge inside the bubble's radius; the bubble adds no
81
- * padding around it.
94
+ * The rich-media slot — an image, a voice note, a file row, a poll. It is
95
+ * rendered ABOVE the text; `mediaFit` decides whether it bleeds to the
96
+ * bubble's radius or keeps its padding.
82
97
  */
83
98
  media?: ReactNode;
99
+ /**
100
+ * How `media` is fitted. Default `'bleed'` — today's photo. Pass `'inset'`
101
+ * for a block that draws no padding of its own (`PollMessage`,
102
+ * `LocationMessage`, `ContactMessage`). See {@link MessageMediaFit}.
103
+ */
104
+ mediaFit?: MessageMediaFit;
84
105
  /**
85
106
  * Floats the meta row over the media on a translucent pill instead of
86
- * tucking it after the text. Defaults to true when there is `media` and no
87
- * text or children.
107
+ * tucking it after the text. Defaults to true on a BLED media-only bubble,
108
+ * and false on an inset one — an inset block's last line is text, and a pill
109
+ * over "0 votes" or over an address is the same defect twice. Set it either
110
+ * way to override.
88
111
  */
89
112
  metaOverlay?: boolean;
90
113
  /** Draws the notch on the small corner. Off by default. */
@@ -0,0 +1,118 @@
1
+ import React, { memo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
4
+
5
+ import { useSvgIdPrefix } from '../styles/svg-id';
6
+
7
+ /**
8
+ * The floating header's EDGE EFFECT: the page colour at the top, fading to
9
+ * nothing below the islands.
10
+ *
11
+ * ── WHY IT IS A GRADIENT AND NOT A BLUR ─────────────────────────────────────
12
+ *
13
+ * It is the thing that lets a title sit on a photograph and still be read, and
14
+ * it has exactly one job: no horizontal line anywhere. A translucent rectangle
15
+ * — even a blurred one — ends at its own bottom edge, and that edge is a line
16
+ * across the screen whether or not it is drawn. Apple's scroll edge effect is
17
+ * described the same way: it protects legibility at the edge and must not read
18
+ * as a slab over the content.
19
+ *
20
+ * It is NOT a blur of varying intensity, and saying so matters because the two
21
+ * are easy to conflate and only one of them is here. `backdrop-filter` and
22
+ * `expo-blur` both take a single radius for the whole surface; a falloff would
23
+ * need the ten stacked layers `progressive-blur` uses, which is a real cost for
24
+ * a strip this size. This is an opacity ramp of one flat colour, and that is
25
+ * all it claims to be.
26
+ *
27
+ * ── THE STOPS ───────────────────────────────────────────────────────────────
28
+ *
29
+ * Four of them, shaped as an ease-out rather than a straight line. A LINEAR
30
+ * ramp of opacity is perceptually not linear: it holds too much colour through
31
+ * the middle and then falls off visibly in the last fifth, which puts a soft
32
+ * but findable band across the content. Weighting the fall earlier spends the
33
+ * gradient's height where the eye is looking — the region behind the title —
34
+ * and arrives at zero gently.
35
+ *
36
+ * `TAIL_RATIO` extends the ramp BELOW the header's own box. The header is as
37
+ * tall as its islands plus its padding; the fade needs room after that, or it
38
+ * finishes at the same place the layout does, which is the line again.
39
+ *
40
+ * ── `stopOpacity`, NOT AN `rgba()` STOP ─────────────────────────────────────
41
+ *
42
+ * `react-native-svg` parses `stopColor` for its RGB and DISCARDS the alpha
43
+ * channel, so an `rgba(…, 0.4)` stop renders at FULL strength on native and
44
+ * correctly on web. That is the exact defect documented on `GLASS_SHEEN` in
45
+ * `theme/glass-colors.ts`, where it painted an Android pane as an achromatic
46
+ * white-to-black wipe while every gate stayed green. The colour and the opacity
47
+ * travel here in separate props for the same reason.
48
+ *
49
+ * ── `width="100%"` IS NOT REDUNDANT ─────────────────────────────────────────
50
+ *
51
+ * An `<svg>` is a REPLACED element. With `width: auto` its used width is its
52
+ * INTRINSIC width, and an SVG with no intrinsic size falls back to CSS's
53
+ * default 300 x 150 — so `position: absolute; left: 0; right: 0` does NOT
54
+ * stretch it: `right` is over-constrained and dropped. Measured in Chrome
55
+ * before this prop existed: a 760px-wide header painted a 300 x 150 block of
56
+ * scrim with a hard vertical edge down the middle of the screen, over content
57
+ * that was otherwise untouched.
58
+ *
59
+ * Native is unaffected — react-native-svg lays its host view out with flexbox
60
+ * there — which is exactly why the omission survives review: it is correct on
61
+ * the platform most of this library is tested on. Gate:
62
+ * `src/__tests__/svg-absolute-fill-size.test.ts`.
63
+ *
64
+ * ── THE GRADIENT ID ─────────────────────────────────────────────────────────
65
+ *
66
+ * From `useSvgIdPrefix`, which is `useId` with React's punctuation stripped.
67
+ * Two headers on one page (a split view) must not resolve each other's
68
+ * `url(#…)`, and a module-scope counter — which is what `GlassSurface` uses,
69
+ * and which is right for a native-only surface — increments in a different
70
+ * order on the server than during hydration, so an SSR'd page would reference a
71
+ * gradient that is not there.
72
+ */
73
+
74
+ /** How far past the header's own height the fade continues, as a ratio of it. */
75
+ export const SCRIM_TAIL_RATIO = 0.55;
76
+
77
+ /**
78
+ * Offset down the ramp -> how much of the page colour survives there.
79
+ *
80
+ * Read as: solid behind the title, nine tenths gone by three quarters of the
81
+ * way down, nothing at the bottom.
82
+ */
83
+ export const SCRIM_STOPS = [
84
+ { offset: 0, opacity: 1 },
85
+ { offset: 0.42, opacity: 0.86 },
86
+ { offset: 0.72, opacity: 0.38 },
87
+ { offset: 1, opacity: 0 },
88
+ ] as const;
89
+
90
+ export interface EdgeScrimProps {
91
+ /** The page colour the ramp starts from — opaque, alpha travels separately. */
92
+ color: string;
93
+ testID?: string;
94
+ }
95
+
96
+ const EdgeScrimComponent: React.FC<EdgeScrimProps> = ({ color, testID }) => {
97
+ const id = `${useSvgIdPrefix('bloom-page-header-scrim')}gradient`;
98
+ return (
99
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill} testID={testID}>
100
+ <Defs>
101
+ <LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
102
+ {SCRIM_STOPS.map((stop) => (
103
+ <Stop
104
+ key={stop.offset}
105
+ offset={String(stop.offset)}
106
+ stopColor={color}
107
+ stopOpacity={stop.opacity}
108
+ />
109
+ ))}
110
+ </LinearGradient>
111
+ </Defs>
112
+ <Rect x="0" y="0" width="100%" height="100%" fill={`url(#${id})`} />
113
+ </Svg>
114
+ );
115
+ };
116
+
117
+ export const EdgeScrim = memo(EdgeScrimComponent);
118
+ EdgeScrim.displayName = 'PageHeaderEdgeScrim';