@oxy.so/bloom 7.3.0 → 7.5.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 (160) hide show
  1. package/docs/adoption-matrix.mdx +9 -9
  2. package/docs/appearance.mdx +1 -1
  3. package/docs/card.mdx +14 -0
  4. package/docs/content-panel.mdx +17 -0
  5. package/docs/layout.mdx +33 -0
  6. package/docs/mail-thread.mdx +9 -1
  7. package/docs/page-footer.mdx +13 -0
  8. package/docs/page-header.mdx +7 -0
  9. package/lib/commonjs/card/Card.js +6 -2
  10. package/lib/commonjs/card/Card.js.map +1 -1
  11. package/lib/commonjs/content-panel/ContentPanel.js +10 -7
  12. package/lib/commonjs/content-panel/ContentPanel.js.map +1 -1
  13. package/lib/commonjs/content-panel/ContentPanel.web.js +12 -7
  14. package/lib/commonjs/content-panel/ContentPanel.web.js.map +1 -1
  15. package/lib/commonjs/index.js +21 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/index.web.js +21 -0
  18. package/lib/commonjs/index.web.js.map +1 -1
  19. package/lib/commonjs/layout/ScrollArea.js +41 -0
  20. package/lib/commonjs/layout/ScrollArea.js.map +1 -0
  21. package/lib/commonjs/layout/index.js +21 -0
  22. package/lib/commonjs/layout/index.js.map +1 -1
  23. package/lib/commonjs/layout/screen-scope.js +6 -2
  24. package/lib/commonjs/layout/screen-scope.js.map +1 -1
  25. package/lib/commonjs/layout/scroll-metrics.js +50 -0
  26. package/lib/commonjs/layout/scroll-metrics.js.map +1 -0
  27. package/lib/commonjs/layout/scroll-offset.js +5 -1
  28. package/lib/commonjs/layout/scroll-offset.js.map +1 -1
  29. package/lib/commonjs/layout/use-scroll-metrics-binding.js +54 -0
  30. package/lib/commonjs/layout/use-scroll-metrics-binding.js.map +1 -0
  31. package/lib/commonjs/mail-thread/MailMessage.js +1 -1
  32. package/lib/commonjs/mail-thread/MailMessage.js.map +1 -1
  33. package/lib/commonjs/mail-thread/MailThread.js +3 -4
  34. package/lib/commonjs/mail-thread/MailThread.js.map +1 -1
  35. package/lib/commonjs/mail-thread/shared.js +3 -3
  36. package/lib/commonjs/mail-thread/shared.js.map +1 -1
  37. package/lib/commonjs/page-footer/PageFooter.js +10 -2
  38. package/lib/commonjs/page-footer/PageFooter.js.map +1 -1
  39. package/lib/commonjs/page-header/PageHeader.js +4 -2
  40. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  41. package/lib/module/card/Card.js +6 -2
  42. package/lib/module/card/Card.js.map +1 -1
  43. package/lib/module/content-panel/ContentPanel.js +11 -8
  44. package/lib/module/content-panel/ContentPanel.js.map +1 -1
  45. package/lib/module/content-panel/ContentPanel.web.js +13 -8
  46. package/lib/module/content-panel/ContentPanel.web.js.map +1 -1
  47. package/lib/module/index.js +1 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/index.web.js +1 -1
  50. package/lib/module/index.web.js.map +1 -1
  51. package/lib/module/layout/ScrollArea.js +36 -0
  52. package/lib/module/layout/ScrollArea.js.map +1 -0
  53. package/lib/module/layout/index.js +3 -0
  54. package/lib/module/layout/index.js.map +1 -1
  55. package/lib/module/layout/screen-scope.js +6 -2
  56. package/lib/module/layout/screen-scope.js.map +1 -1
  57. package/lib/module/layout/scroll-metrics.js +42 -0
  58. package/lib/module/layout/scroll-metrics.js.map +1 -0
  59. package/lib/module/layout/scroll-offset.js +5 -1
  60. package/lib/module/layout/scroll-offset.js.map +1 -1
  61. package/lib/module/layout/use-scroll-metrics-binding.js +51 -0
  62. package/lib/module/layout/use-scroll-metrics-binding.js.map +1 -0
  63. package/lib/module/mail-thread/MailMessage.js +1 -1
  64. package/lib/module/mail-thread/MailMessage.js.map +1 -1
  65. package/lib/module/mail-thread/MailThread.js +1 -2
  66. package/lib/module/mail-thread/MailThread.js.map +1 -1
  67. package/lib/module/mail-thread/shared.js +3 -3
  68. package/lib/module/mail-thread/shared.js.map +1 -1
  69. package/lib/module/page-footer/PageFooter.js +10 -2
  70. package/lib/module/page-footer/PageFooter.js.map +1 -1
  71. package/lib/module/page-header/PageHeader.js +4 -2
  72. package/lib/module/page-header/PageHeader.js.map +1 -1
  73. package/lib/typescript/commonjs/card/Card.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/card/types.d.ts +5 -5
  75. package/lib/typescript/commonjs/card/types.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/content-panel/types.d.ts +7 -0
  79. package/lib/typescript/commonjs/content-panel/types.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/index.d.ts +2 -2
  81. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  83. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/layout/ScrollArea.d.ts +11 -0
  85. package/lib/typescript/commonjs/layout/ScrollArea.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/layout/index.d.ts +5 -0
  87. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/layout/screen-scope.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/layout/scroll-metrics.d.ts +15 -0
  90. package/lib/typescript/commonjs/layout/scroll-metrics.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/layout/scroll-offset.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/layout/use-scroll-metrics-binding.d.ts +11 -0
  93. package/lib/typescript/commonjs/layout/use-scroll-metrics-binding.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/mail-thread/MailThread.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/mail-thread/shared.d.ts +2 -2
  96. package/lib/typescript/commonjs/mail-thread/shared.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/note-card/shared.d.ts +1 -1
  98. package/lib/typescript/commonjs/note-card/shared.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts +1 -1
  100. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/page-footer/types.d.ts +8 -2
  102. package/lib/typescript/commonjs/page-footer/types.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/page-header/types.d.ts +3 -4
  105. package/lib/typescript/commonjs/page-header/types.d.ts.map +1 -1
  106. package/lib/typescript/module/card/Card.d.ts.map +1 -1
  107. package/lib/typescript/module/card/types.d.ts +5 -5
  108. package/lib/typescript/module/card/types.d.ts.map +1 -1
  109. package/lib/typescript/module/content-panel/ContentPanel.d.ts.map +1 -1
  110. package/lib/typescript/module/content-panel/ContentPanel.web.d.ts.map +1 -1
  111. package/lib/typescript/module/content-panel/types.d.ts +7 -0
  112. package/lib/typescript/module/content-panel/types.d.ts.map +1 -1
  113. package/lib/typescript/module/index.d.ts +2 -2
  114. package/lib/typescript/module/index.d.ts.map +1 -1
  115. package/lib/typescript/module/index.web.d.ts +2 -2
  116. package/lib/typescript/module/index.web.d.ts.map +1 -1
  117. package/lib/typescript/module/layout/ScrollArea.d.ts +11 -0
  118. package/lib/typescript/module/layout/ScrollArea.d.ts.map +1 -0
  119. package/lib/typescript/module/layout/index.d.ts +5 -0
  120. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  121. package/lib/typescript/module/layout/screen-scope.d.ts.map +1 -1
  122. package/lib/typescript/module/layout/scroll-metrics.d.ts +15 -0
  123. package/lib/typescript/module/layout/scroll-metrics.d.ts.map +1 -0
  124. package/lib/typescript/module/layout/scroll-offset.d.ts.map +1 -1
  125. package/lib/typescript/module/layout/use-scroll-metrics-binding.d.ts +11 -0
  126. package/lib/typescript/module/layout/use-scroll-metrics-binding.d.ts.map +1 -0
  127. package/lib/typescript/module/mail-thread/MailThread.d.ts.map +1 -1
  128. package/lib/typescript/module/mail-thread/shared.d.ts +2 -2
  129. package/lib/typescript/module/mail-thread/shared.d.ts.map +1 -1
  130. package/lib/typescript/module/note-card/shared.d.ts +1 -1
  131. package/lib/typescript/module/note-card/shared.d.ts.map +1 -1
  132. package/lib/typescript/module/page-footer/PageFooter.d.ts +1 -1
  133. package/lib/typescript/module/page-footer/PageFooter.d.ts.map +1 -1
  134. package/lib/typescript/module/page-footer/types.d.ts +8 -2
  135. package/lib/typescript/module/page-footer/types.d.ts.map +1 -1
  136. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  137. package/lib/typescript/module/page-header/types.d.ts +3 -4
  138. package/lib/typescript/module/page-header/types.d.ts.map +1 -1
  139. package/package.json +1 -1
  140. package/src/card/Card.tsx +6 -2
  141. package/src/card/types.ts +5 -5
  142. package/src/content-panel/ContentPanel.tsx +13 -10
  143. package/src/content-panel/ContentPanel.web.tsx +13 -9
  144. package/src/content-panel/types.ts +7 -0
  145. package/src/index.ts +4 -1
  146. package/src/index.web.ts +4 -1
  147. package/src/layout/ScrollArea.tsx +25 -0
  148. package/src/layout/index.ts +6 -0
  149. package/src/layout/screen-scope.tsx +2 -1
  150. package/src/layout/scroll-metrics.tsx +35 -0
  151. package/src/layout/scroll-offset.ts +5 -1
  152. package/src/layout/use-scroll-metrics-binding.ts +37 -0
  153. package/src/mail-thread/MailMessage.tsx +1 -1
  154. package/src/mail-thread/MailThread.tsx +0 -2
  155. package/src/mail-thread/shared.ts +3 -3
  156. package/src/note-card/shared.ts +1 -1
  157. package/src/page-footer/PageFooter.tsx +11 -4
  158. package/src/page-footer/types.ts +8 -2
  159. package/src/page-header/PageHeader.tsx +4 -2
  160. package/src/page-header/types.ts +3 -4
@@ -38,7 +38,7 @@ import { StyleSheet, useWindowDimensions } from 'react-native';
38
38
  import { useOptionalPanelChrome, usePanelShape } from '../styles/panel-chrome';
39
39
  import { surfaceStyle as resolveShapeStyle } from '../shapes/surface-style';
40
40
  import { StyledView } from '../styles/styled-primitives';
41
- import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill } from '../styles/surface-levels';
41
+ import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill, useSurfaceLevelValue } from '../styles/surface-levels';
42
42
  import {
43
43
  ContentPanelNestingContext,
44
44
  useContentPanelNestingGuard,
@@ -80,6 +80,7 @@ const RESPONSIVE_CONTENT: Record<ContentPanelFramedBreakpoint, string> = {
80
80
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
81
81
  children,
82
82
  errorBoundary = false,
83
+ appearance = 'solid',
83
84
  framed,
84
85
  framedFrom = 768,
85
86
  fill = false,
@@ -100,8 +101,10 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
100
101
  const defaultFill = rawFill;
101
102
  const { width } = useWindowDimensions();
102
103
  const parentFill = useOptionalSurfaceFill();
103
- const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
104
- const publishedFill = rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
104
+ const parentLevel = useSurfaceLevelValue();
105
+ const isPlain = appearance === 'plain';
106
+ const paintsSurface = !isPlain && Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
107
+ const publishedFill = isPlain ? parentFill : rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
105
108
  const radius = StyleSheet.flatten(surfaceStyle)?.borderRadius ?? panelShape.radius;
106
109
  const isFramed = framed ?? width >= framedFrom;
107
110
  const geometry = { ...resolveShapeStyle({ curve: panelShape.curve }), borderRadius: isFramed ? radius : 0 };
@@ -113,18 +116,18 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
113
116
  // (the breakpoint bundle lives whole in `RESPONSIVE_SURFACE`; `framed ===
114
117
  // true`/`false` ignore `framedFrom` for fixed always/never framing).
115
118
  const surfaceBase =
116
- chrome === 'none'
119
+ isPlain || chrome === 'none'
117
120
  ? 'flex-1'
118
121
  : framed === undefined
119
122
  ? `flex-1 ${RESPONSIVE_SURFACE[framedFrom]}`
120
123
  : framed
121
124
  ? 'flex-1 border border-border'
122
125
  : 'flex-1';
123
- const surfaceClass = [surfaceBase, fill ? 'min-h-0' : '', surfaceClassName ?? (paintsSurface ? 'bg-transparent' : 'bg-card')].filter(Boolean).join(' ');
126
+ const surfaceClass = [surfaceBase, fill ? 'min-h-0' : '', surfaceClassName ?? (isPlain || paintsSurface ? 'bg-transparent' : 'bg-card')].filter(Boolean).join(' ');
124
127
  // Native has no overlays: the surface itself carries the edge. `none` drops
125
128
  // both, `border` keeps the class-drawn hairline, `elevated` adds the lift.
126
129
  const chromeStyle =
127
- !isFramed || chrome === 'none' || chrome === 'border'
130
+ !isFramed || isPlain || chrome === 'none' || chrome === 'border'
128
131
  ? null
129
132
  : shadow || panelChrome
130
133
  ? { boxShadow: shadow ?? panelChrome?.shadow }
@@ -149,14 +152,14 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
149
152
  geometry,
150
153
  chromeStyle,
151
154
  surfaceStyle,
152
- paintsSurface ? { backgroundColor: 'transparent' } : null,
155
+ isPlain || paintsSurface ? { backgroundColor: 'transparent' } : null,
153
156
  ]}
154
157
  >
155
158
  {paintsSurface ? <SurfacePaint fill={defaultFill!} radius={radius} shape={{ curve: panelShape.curve }} /> : null}
156
159
  <StyledView testID="content-panel-content" className={contentClass} style={[geometry, contentStyle]}>
157
- {/* The panel is a surface: everything inside is sitting on rung 1,
158
- painted in the colour this panel actually paints. */}
159
- <SurfaceLevelProvider level={1} fill={publishedFill}>
160
+ {/* Solid panels publish their painted surface. Plain panels preserve
161
+ the enclosing surface so descendant scrims follow its real fill. */}
162
+ <SurfaceLevelProvider level={isPlain ? parentLevel : 1} fill={publishedFill}>
160
163
  {errorBoundary === false ? children : <PanelErrorBoundary {...(errorBoundary === true ? {} : errorBoundary)}>{children}</PanelErrorBoundary>}
161
164
  </SurfaceLevelProvider>
162
165
  </StyledView>
@@ -97,7 +97,7 @@ import { surfaceStyle as resolveShapeStyle } from '../shapes/surface-style';
97
97
  import { StyledView } from '../styles/styled-primitives';
98
98
 
99
99
  import { useOptionalPanelChrome, usePanelShape } from '../styles/panel-chrome';
100
- import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill } from '../styles/surface-levels';
100
+ import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill, useSurfaceLevelValue } from '../styles/surface-levels';
101
101
  import { useTheme } from '../theme/use-theme';
102
102
  import {
103
103
  ContentPanelNestingContext,
@@ -173,6 +173,7 @@ const PANEL_INSET_CSS = `
173
173
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
174
174
  children,
175
175
  errorBoundary = false,
176
+ appearance = 'solid',
176
177
  framed,
177
178
  framedFrom = 768,
178
179
  fill = false,
@@ -211,8 +212,10 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
211
212
  const defaultFill = rawFill;
212
213
  const { width } = useWindowDimensions();
213
214
  const parentFill = useOptionalSurfaceFill();
214
- const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
215
- const publishedFill = rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
215
+ const parentLevel = useSurfaceLevelValue();
216
+ const isPlain = appearance === 'plain';
217
+ const paintsSurface = !isPlain && Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
218
+ const publishedFill = isPlain ? parentFill : rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
216
219
  const materialStyle = useSurfaceMaterial('[data-bloom-panel-material]', 'bloom-content-panel-material', defaultFill ?? 'transparent');
217
220
 
218
221
  // Tri-state: `undefined` → responsive (md:-gated), `true` → always framed,
@@ -257,7 +260,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
257
260
  const gridStackClass = boundToPanel
258
261
  ? 'web:grid web:[grid-template-columns:minmax(0,1fr)] web:[grid-template-rows:minmax(0,1fr)]'
259
262
  : '';
260
- const surfaceClass = [surfaceBase, fillSurface, gridStackClass, surfaceClassName ?? (paintsSurface ? 'bg-transparent' : 'bg-card')].filter(Boolean).join(' ');
263
+ const surfaceClass = [surfaceBase, fillSurface, gridStackClass, surfaceClassName ?? (isPlain || paintsSurface ? 'bg-transparent' : 'bg-card')].filter(Boolean).join(' ');
261
264
  const contentClass = [contentBase, fillContent, boundToPanel ? 'web:[grid-area:1/1]' : '', contentClassName]
262
265
  .filter(Boolean)
263
266
  .join(' ');
@@ -313,6 +316,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
313
316
  framed === true ? { borderRadius: radius } : framed === false ? { borderRadius: 0 } : null,
314
317
  surfaceStyle,
315
318
  paintsSurface ? materialStyle : null,
319
+ isPlain ? { backgroundColor: 'transparent' } : null,
316
320
  ]}
317
321
  >
318
322
  {/* (1) Bleed-mask overlay — gutter box-shadow ring, below chrome. Not
@@ -331,12 +335,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
331
335
  ? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-[var(--bloom-panel-radius)] ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
332
336
  : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-[var(--bloom-panel-radius)] web:[margin-bottom:calc(-100dvh+16px)]'
333
337
  }
334
- style={{ ...panelStyle, ...insetStyle, boxShadow: `0 0 0 ${maskSpread}px ${maskColor ?? colors.background}` }}
338
+ style={{ ...panelStyle, ...insetStyle, boxShadow: `0 0 0 ${maskSpread}px ${maskColor ?? (isPlain ? parentFill : undefined) ?? colors.background}` }}
335
339
  />
336
340
  )}
337
341
  {/* (2) Border-frame overlay — one continuous rounded border, above all.
338
342
  Same visibility gating as the bleed-mask. */}
339
- {showOverlays && showStickyFrame !== false && chrome !== 'none' && (
343
+ {showOverlays && showStickyFrame !== false && !isPlain && chrome !== 'none' && (
340
344
  <StyledView
341
345
  key="border-frame"
342
346
  testID="content-panel-border-frame"
@@ -361,9 +365,9 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
361
365
  scroll/virtualizer + refetch on a breakpoint cross). Clipped to the
362
366
  rounded panel shape on web when framed. */}
363
367
  <StyledView key="content" testID="content-panel-content" className={contentClass} style={[curveStyle, framed === true ? { borderRadius: radius } : framed === false ? { borderRadius: 0 } : null, contentStyle]}>
364
- {/* The panel is a surface: everything inside is sitting on rung 1,
365
- painted in the colour this panel actually paints. */}
366
- <SurfaceLevelProvider level={1} fill={publishedFill}>
368
+ {/* Solid panels publish their painted surface. Plain panels preserve
369
+ the enclosing surface so descendant scrims follow its real fill. */}
370
+ <SurfaceLevelProvider level={isPlain ? parentLevel : 1} fill={publishedFill}>
367
371
  {errorBoundary === false ? children : <PanelErrorBoundary {...(errorBoundary === true ? {} : errorBoundary)}>{children}</PanelErrorBoundary>}
368
372
  </SurfaceLevelProvider>
369
373
  </StyledView>
@@ -18,6 +18,13 @@ export type ContentPanelChrome = 'elevated' | 'border' | 'none';
18
18
 
19
19
  export interface ContentPanelProps {
20
20
  children: React.ReactNode;
21
+ /**
22
+ * `solid` (default) paints a panel surface. `plain` keeps layout, framing and
23
+ * clipping while leaving the enclosing surface visible, without material,
24
+ * border or shadow. Descendants inherit the parent's surface level and fill.
25
+ * In plain mode surface background overrides and surfaceColor are ignored.
26
+ */
27
+ appearance?: 'solid' | 'plain';
21
28
  /** Opt in to local themed error recovery. Split panes already provide their own boundaries. */
22
29
  errorBoundary?: boolean | PanelErrorBoundaryOptions;
23
30
  /**
package/src/index.ts CHANGED
@@ -169,13 +169,16 @@ export {
169
169
  useClaimTopEdge,
170
170
  useTopEdgeInset,
171
171
  ScrollOffsetProvider,
172
+ ScrollMetricsProvider,
173
+ useScrollMetricsBinding,
174
+ ScrollArea,
172
175
  useScrollOffset,
173
176
  ScreenScope,
174
177
  HeaderDockProvider,
175
178
  useHeaderDockInset,
176
179
  StickySection,
177
180
  } from './layout';
178
- export type { StickySectionProps } from './layout';
181
+ export type { StickySectionProps, ScrollMetricsBindingOptions, ScrollAreaProps } from './layout';
179
182
  export { Fab } from './fab';
180
183
  export type { FabProps, FabSize } from './fab';
181
184
  export { FrostedIconButton } from './frosted-icon-button';
package/src/index.web.ts CHANGED
@@ -174,13 +174,16 @@ export {
174
174
  useClaimTopEdge,
175
175
  useTopEdgeInset,
176
176
  ScrollOffsetProvider,
177
+ ScrollMetricsProvider,
178
+ useScrollMetricsBinding,
179
+ ScrollArea,
177
180
  useScrollOffset,
178
181
  ScreenScope,
179
182
  HeaderDockProvider,
180
183
  useHeaderDockInset,
181
184
  StickySection,
182
185
  } from './layout';
183
- export type { StickySectionProps } from './layout';
186
+ export type { StickySectionProps, ScrollMetricsBindingOptions, ScrollAreaProps } from './layout';
184
187
  export { Fab } from './fab/index.web';
185
188
  export type { FabProps, FabSize } from './fab/index.web';
186
189
  export { FrostedIconButton } from './frosted-icon-button/index.web';
@@ -0,0 +1,25 @@
1
+ import React, { forwardRef, type ComponentType, type Ref } from 'react';
2
+ import { ScrollView, type ScrollViewProps } from 'react-native';
3
+ import { styled } from 'react-native-css';
4
+ import { useScrollMetricsBinding } from './use-scroll-metrics-binding';
5
+
6
+ export interface ScrollAreaProps extends ScrollViewProps {
7
+ /** Utilities for the scrolling viewport, resolved by Bloom's own interop. */
8
+ className?: string;
9
+ /** Utilities for the inner content container. */
10
+ contentContainerClassName?: string;
11
+ }
12
+
13
+ // Narrow only the styled mapping input to avoid ScrollViewProps' recursive
14
+ // dot-path union. The public component retains the complete ScrollView contract.
15
+ const ScrollAreaBase: ComponentType<Pick<ScrollViewProps, 'style' | 'contentContainerStyle'>> = ScrollView;
16
+ const StyledScrollView: ComponentType<ScrollAreaProps & { ref?: Ref<ScrollView> }> = styled(ScrollAreaBase, {
17
+ className: 'style',
18
+ contentContainerClassName: 'contentContainerStyle',
19
+ });
20
+
21
+ /** A ScrollView that publishes its measurements to surrounding page chrome. */
22
+ export const ScrollArea = forwardRef<ScrollView, ScrollAreaProps>(function ScrollArea({ onScroll, onLayout, onContentSizeChange, scrollEventThrottle, ...props }, ref) {
23
+ const binding = useScrollMetricsBinding({ onScroll, onLayout, onContentSizeChange });
24
+ return <StyledScrollView {...props} {...binding} scrollEventThrottle={scrollEventThrottle ?? binding.scrollEventThrottle} ref={ref} />;
25
+ });
@@ -18,3 +18,9 @@ export { ScreenScope } from './screen-scope';
18
18
  export { HeaderDockProvider, useHeaderDockInset } from './header-dock';
19
19
  export { StickySection } from './StickySection';
20
20
  export type { StickySectionProps } from './StickySection';
21
+
22
+ export { ScrollMetricsProvider } from './scroll-metrics';
23
+ export { useScrollMetricsBinding } from './use-scroll-metrics-binding';
24
+ export type { ScrollMetricsBindingOptions } from './use-scroll-metrics-binding';
25
+ export { ScrollArea } from './ScrollArea';
26
+ export type { ScrollAreaProps } from './ScrollArea';
@@ -39,6 +39,7 @@ import { ScreenContext, ScreenNavigationContext } from '../screen/context';
39
39
  import type { PropsWithChildren } from 'react';
40
40
 
41
41
  import { BottomEdgeProvider } from './bottom-edge';
42
+ import { ScrollMetricsContext } from './scroll-metrics';
42
43
  import { ScrollOffsetProvider } from './scroll-offset';
43
44
  import { TopEdgeProvider } from './top-edge';
44
45
 
@@ -49,7 +50,7 @@ export function ScreenScope({ children }: PropsWithChildren) {
49
50
  <TopEdgeProvider>
50
51
  <BottomEdgeProvider>
51
52
  {/* null means no scroller has claimed this scope yet. */}
52
- <ScrollOffsetProvider value={null}>{children}</ScrollOffsetProvider>
53
+ <ScrollMetricsContext.Provider value={null}><ScrollOffsetProvider value={null}>{children}</ScrollOffsetProvider></ScrollMetricsContext.Provider>
53
54
  </BottomEdgeProvider>
54
55
  </TopEdgeProvider>
55
56
  </ScreenNavigationContext.Provider>
@@ -0,0 +1,35 @@
1
+ import React, { createContext, useContext, useMemo, type PropsWithChildren } from 'react';
2
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated';
3
+ import { ScrollOffsetProvider } from './scroll-offset';
4
+
5
+ export interface ScrollMetrics {
6
+ scrollY: SharedValue<number>;
7
+ contentHeight: SharedValue<number>;
8
+ viewportHeight: SharedValue<number>;
9
+ }
10
+
11
+ /** Internal scope boundary; overlay screens must not follow the page beneath them. */
12
+ export const ScrollMetricsContext = createContext<ScrollMetrics | null>(null);
13
+
14
+ /** Pair one scroller with its surrounding header/footer without adding a layout box. */
15
+ export function ScrollMetricsProvider({ children }: PropsWithChildren) {
16
+ const scrollY = useSharedValue(0);
17
+ const contentHeight = useSharedValue(-1);
18
+ const viewportHeight = useSharedValue(0);
19
+ const value = useMemo(() => ({ scrollY, contentHeight, viewportHeight }), [scrollY, contentHeight, viewportHeight]);
20
+ return <ScrollMetricsContext.Provider value={value}>
21
+ <ScrollOffsetProvider value={scrollY}>{children}</ScrollOffsetProvider>
22
+ </ScrollMetricsContext.Provider>;
23
+ }
24
+
25
+ export function useScrollMetricsValue(): ScrollMetrics | null {
26
+ return useContext(ScrollMetricsContext);
27
+ }
28
+
29
+ /** Unmeasured and non-scrollable content has no hidden bottom edge. */
30
+ export function remainingScrollDistance(metrics: ScrollMetrics | null): number {
31
+ 'worklet';
32
+ if (!metrics || metrics.viewportHeight.value <= 0 || metrics.contentHeight.value < 0) return 0;
33
+ const max = Math.max(0, metrics.contentHeight.value - metrics.viewportHeight.value);
34
+ return Math.max(0, max - Math.max(0, metrics.scrollY.value));
35
+ }
@@ -8,7 +8,11 @@
8
8
  * one scroll container has moved. The container is the screen's, and the chrome
9
9
  * is usually not inside it, so the number has to travel.
10
10
  *
11
- * Until now it travelled as a prop, and the default when the prop was omitted
11
+ * Prefer ScrollMetricsProvider with ScrollArea when the footer also needs to
12
+ * know the remaining content. This lower-level offset-only provider remains
13
+ * useful for an existing animated scroll owner.
14
+ *
15
+ * Originally it travelled as a prop, and the default when the prop was omitted
12
16
  * was `window.scrollY` on web and "nothing moves" on native. That default is
13
17
  * right for exactly one layout — a web page whose document IS the scroller —
14
18
  * and silently wrong for the two that a Bloom app usually has: a desktop panel
@@ -0,0 +1,37 @@
1
+ import { useCallback } from 'react';
2
+ import type { ScrollViewProps } from 'react-native';
3
+ import { useScrollMetricsValue } from './scroll-metrics';
4
+
5
+ /** Events accepted by ordinary RN ScrollView, FlatList and SectionList. */
6
+ export type ScrollMetricsBindingOptions = Pick<ScrollViewProps, 'onScroll' | 'onLayout' | 'onContentSizeChange'>;
7
+
8
+ /** Bind one scrolling owner to the nearest ScrollMetricsProvider. No React updates per frame. */
9
+ export function useScrollMetricsBinding({ onScroll, onLayout, onContentSizeChange }: ScrollMetricsBindingOptions = {}) {
10
+ const metrics = useScrollMetricsValue();
11
+ const handleScroll = useCallback<NonNullable<ScrollViewProps['onScroll']>>(event => {
12
+ if (metrics) {
13
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
14
+ if (Number.isFinite(contentSize.height)) metrics.contentHeight.value = Math.max(0, contentSize.height);
15
+ if (Number.isFinite(layoutMeasurement.height)) metrics.viewportHeight.value = Math.max(0, layoutMeasurement.height);
16
+ const max = Math.max(0, metrics.contentHeight.value - metrics.viewportHeight.value);
17
+ if (Number.isFinite(contentOffset.y)) metrics.scrollY.value = Math.min(max, Math.max(0, contentOffset.y));
18
+ }
19
+ onScroll?.(event);
20
+ }, [metrics, onScroll]);
21
+ const handleLayout = useCallback<NonNullable<ScrollViewProps['onLayout']>>(event => {
22
+ const height = event.nativeEvent.layout.height;
23
+ if (metrics && Number.isFinite(height)) {
24
+ metrics.viewportHeight.value = Math.max(0, height);
25
+ if (metrics.contentHeight.value >= 0) metrics.scrollY.value = Math.min(metrics.scrollY.value, Math.max(0, metrics.contentHeight.value - height));
26
+ }
27
+ onLayout?.(event);
28
+ }, [metrics, onLayout]);
29
+ const handleContentSizeChange = useCallback<NonNullable<ScrollViewProps['onContentSizeChange']>>((width, height) => {
30
+ if (metrics && Number.isFinite(height)) {
31
+ metrics.contentHeight.value = Math.max(0, height);
32
+ metrics.scrollY.value = Math.min(metrics.scrollY.value, Math.max(0, height - metrics.viewportHeight.value));
33
+ }
34
+ onContentSizeChange?.(width, height);
35
+ }, [metrics, onContentSizeChange]);
36
+ return { onScroll: handleScroll, onLayout: handleLayout, onContentSizeChange: handleContentSizeChange, scrollEventThrottle: 16 };
37
+ }
@@ -143,7 +143,7 @@ export function MailMessage({
143
143
  appearance="solid"
144
144
  border="none"
145
145
  elevation="s"
146
- radius="radius-16"
146
+ radius="panel"
147
147
  testID={testID}
148
148
  style={[open ? { paddingBottom: geo.paddingVertical } : null, style]}
149
149
  >
@@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { View } from 'react-native';
3
3
 
4
4
  import { Button } from '../button';
5
- import { Divider } from '../divider';
6
5
  import { useControllableState } from '../hooks/use-controllable-state';
7
6
  import { MailLabelChips, MailStar } from '../mail-list/parts';
8
7
  import { mailStrings, resolveMailPaint, visibleLabels } from '../mail-list/shared';
@@ -145,7 +144,6 @@ export function MailThread({
145
144
  />
146
145
  </View>
147
146
  ) : null}
148
- <Divider />
149
147
  <View testID={testID ? `${testID}-messages` : undefined} style={{ paddingTop: space.xs, paddingBottom: space.xs, gap: messageGap }}>
150
148
  {entries.map((entry) =>
151
149
  entry.kind === 'message' ? (
@@ -1,5 +1,5 @@
1
1
  import { Platform } from 'react-native';
2
- import { RADIUS } from '../design-tokens/scales';
2
+ import { SURFACE_SHAPES } from '../design-tokens/shapes';
3
3
 
4
4
  import { MAIL_THREAD_MESSAGES } from './messages';
5
5
  import type { MailThreadMessage, MailAddress, MailThreadStrings } from './types';
@@ -44,8 +44,8 @@ export const MAIL_THREAD_GEOMETRY = {
44
44
  glyph: 16,
45
45
  /** The star and the overflow trigger. A touch target, so 44. */
46
46
  action: 44,
47
- /** Radius of a message's own surface. */
48
- radius: RADIUS['radius-16'],
47
+ /** Default radius; rendered messages inherit BloomScope.panelRadius via Card. */
48
+ radius: SURFACE_SHAPES.sidebar.radius,
49
49
  } as const;
50
50
 
51
51
  /**
@@ -40,7 +40,7 @@ export interface NoteCardGeometry {
40
40
  padding: number;
41
41
  gap: number;
42
42
  /** A rung of the `RADIUS` scale — `Card` takes the rung, the focus ring takes its px. */
43
- radius: CardRadius;
43
+ radius: Exclude<CardRadius, 'panel'>;
44
44
  excerptLines: number;
45
45
  titleLines: number;
46
46
  maxTags: number;
@@ -1,6 +1,8 @@
1
1
  import React, { memo, useContext, useEffect, useId, useState } from 'react';
2
2
  import { Platform, StyleSheet, View, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
3
3
  import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
4
+ import Animated, { useAnimatedStyle } from 'react-native-reanimated';
5
+ import { remainingScrollDistance, useScrollMetricsValue } from '../layout/scroll-metrics';
4
6
  import { BloomScope } from '../appearance';
5
7
  import { useBottomEdgeInset } from '../layout/bottom-edge';
6
8
  import { EdgeScrim, SCRIM_TAIL_RATIO } from '../page-header/EdgeScrim';
@@ -15,9 +17,14 @@ const BAR_HEIGHT = 56;
15
17
 
16
18
  /** Page-local controls floating over a bounded scroller, above app navigation. */
17
19
  function PageFooterComponent({
18
- children, actions, bottomInset, safeArea, scrim = 'always', scrimColor, style, testID,
20
+ children, actions, bottomInset, safeArea, scrim = 'always', scrimColor, scrollThreshold = 20, style, testID,
19
21
  }: PageFooterProps) {
20
22
  const theme = useTheme();
23
+ const metrics = useScrollMetricsValue();
24
+ const threshold = Math.max(1, Number.isFinite(scrollThreshold) ? scrollThreshold : 20);
25
+ const scrimStyle = useAnimatedStyle(() => ({
26
+ opacity: scrim === 'auto' ? Math.min(1, remainingScrollDistance(metrics) / threshold) : 1,
27
+ }), [metrics, scrim, threshold]);
21
28
  const fill = useSurfaceFill() ?? theme.colors.background;
22
29
  const occupied = useBottomEdgeInset();
23
30
  const rawBottom = bottomInset ?? occupied;
@@ -51,13 +58,13 @@ function PageFooterComponent({
51
58
  style={[styles.container, { bottom, paddingBottom: padBottom }, style]}
52
59
  >
53
60
  {scrim !== 'none' ? (
54
- <View
61
+ <Animated.View
55
62
  pointerEvents="none"
56
- style={[styles.scrim, { height: height * (1 + SCRIM_TAIL_RATIO) }]}
63
+ style={[styles.scrim, { height: height * (1 + SCRIM_TAIL_RATIO) }, scrimStyle]}
57
64
  testID={testID ? `${testID}-scrim` : undefined}
58
65
  >
59
66
  <EdgeScrim edge="bottom" color={scrimColor ?? fill} testID={testID ? `${testID}-scrim-gradient` : undefined} />
60
- </View>
67
+ </Animated.View>
61
68
  ) : null}
62
69
  <View
63
70
  onLayout={onLayout}
@@ -17,8 +17,14 @@ export interface PageFooterProps {
17
17
  * true on native, false on web. Disable when the containing shell owns it.
18
18
  */
19
19
  safeArea?: boolean;
20
- /** The PageHeader gradient, mirrored upward. Default always. */
21
- scrim?: 'always' | 'none';
20
+ /**
21
+ * Mirrored PageHeader gradient. Default always preserves existing behavior.
22
+ * auto follows ScrollMetricsProvider: hidden at the bottom or for short /
23
+ * unmeasured content, fading in over scrollThreshold of remaining content.
24
+ */
25
+ scrim?: 'auto' | 'always' | 'none';
26
+ /** Distance from the bottom over which the automatic scrim fades. Default 20. */
27
+ scrollThreshold?: number;
22
28
  /** Opaque gradient color; defaults to the containing surface's fill. */
23
29
  scrimColor?: string;
24
30
  style?: StyleProp<ViewStyle>;
@@ -24,6 +24,7 @@ import { BUTTON_SHADOW } from '../button/shared';
24
24
  import { BloomScope } from "../appearance";
25
25
  import { GlassIsland } from '../glass';
26
26
  import { RiArrowLeftLine } from '../icons/remix/RiArrowLeftLine';
27
+ import { useScrollMetricsValue } from '../layout/scroll-metrics';
27
28
  import { useClaimTopEdge, useScrollOffset } from '../layout';
28
29
  import { BREAKPOINTS } from '../styles/breakpoints';
29
30
  import { WEB_POSITION_STICKY, WEB_SURFACE_STICKY_TOP, type WebCssStyle } from '../styles/web-view-style';
@@ -186,14 +187,15 @@ function PageHeaderComponent({
186
187
 
187
188
  // ── The scroll owner ──────────────────────────────────────────────────────
188
189
  //
189
- // Prop, then Screen, then the nearest ScrollOffsetProvider, then the document on web.
190
+ // Explicit offset, dock, local metrics, Screen, legacy offset, then the web document.
190
191
  // The context step is the one that matters for a Bloom composition: without
191
192
  // it a header inside a scrolling panel followed `window.scrollY`, which on a
192
193
  // desktop shell never moves, so the header looked deliberately inert.
193
194
  const screen = useScreenContext();
194
195
  const contextScrollY = useScrollOffset();
196
+ const metrics = useScrollMetricsValue();
195
197
  const internalScrollY = useSharedValue(0);
196
- const scrollY = externalScrollY ?? dock?.scrollY ?? screen?.scrollY ?? contextScrollY ?? internalScrollY;
198
+ const scrollY = externalScrollY ?? dock?.scrollY ?? metrics?.scrollY ?? screen?.scrollY ?? contextScrollY ?? internalScrollY;
197
199
  const followsWindow = !externalScrollY && !dock && !screen && !contextScrollY;
198
200
 
199
201
  useEffect(() => {
@@ -142,10 +142,9 @@ export interface PageHeaderProps {
142
142
  /**
143
143
  * The scroll offset driving the scrim, the separator and the shadow.
144
144
  *
145
- * Resolution order: this prop, then the enclosing `Screen` offset, then the nearest `ScrollOffsetProvider`
146
- * (`@oxy.so/bloom/layout`) — which is how a header inside a Bloom scroll
147
- * composition gets it with no wiring — then, on web only, `window.scrollY`.
148
- * On native with none of the three, the header stays at rest.
145
+ * Resolution order: this prop, HeaderDockProvider, ScrollMetricsProvider,
146
+ * the enclosing Screen, then ScrollOffsetProvider. On web with no local
147
+ * owner it follows window.scrollY; on native it stays at rest.
149
148
  */
150
149
  scrollY?: SharedValue<number>;
151
150
  /** The offset (px) over which the scrim, separator and shadow fade in. Default 20. */