@oxy.so/bloom 7.1.5 → 7.2.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 (223) hide show
  1. package/docs/adoption-matrix.mdx +12 -11
  2. package/docs/app-shell.mdx +26 -2
  3. package/docs/appearance.mdx +16 -1
  4. package/docs/content-panel.mdx +4 -0
  5. package/docs/mail-thread.mdx +8 -0
  6. package/docs/page-footer.mdx +60 -0
  7. package/docs/sidebar.mdx +1 -1
  8. package/lib/commonjs/app-shell/AppShell.js +3 -2
  9. package/lib/commonjs/app-shell/AppShell.js.map +1 -1
  10. package/lib/commonjs/app-shell/AppShellBars.js +8 -5
  11. package/lib/commonjs/app-shell/AppShellBars.js.map +1 -1
  12. package/lib/commonjs/app-shell/AppShellEngine.js +81 -36
  13. package/lib/commonjs/app-shell/AppShellEngine.js.map +1 -1
  14. package/lib/commonjs/appearance/BloomScope.js +8 -3
  15. package/lib/commonjs/appearance/BloomScope.js.map +1 -1
  16. package/lib/commonjs/appearance/context.js.map +1 -1
  17. package/lib/commonjs/content-panel/ContentPanel.js +34 -23
  18. package/lib/commonjs/content-panel/ContentPanel.js.map +1 -1
  19. package/lib/commonjs/content-panel/ContentPanel.web.js +39 -17
  20. package/lib/commonjs/content-panel/ContentPanel.web.js.map +1 -1
  21. package/lib/commonjs/design-tokens/shapes.js +2 -2
  22. package/lib/commonjs/design-tokens/shapes.js.map +1 -1
  23. package/lib/commonjs/design-tokens/tokens.json +2 -2
  24. package/lib/commonjs/index.js +939 -927
  25. package/lib/commonjs/index.js.map +1 -1
  26. package/lib/commonjs/index.web.js +939 -927
  27. package/lib/commonjs/index.web.js.map +1 -1
  28. package/lib/commonjs/mail-thread/MailMessage.js +8 -14
  29. package/lib/commonjs/mail-thread/MailMessage.js.map +1 -1
  30. package/lib/commonjs/mail-thread/MailThread.js +6 -2
  31. package/lib/commonjs/mail-thread/MailThread.js.map +1 -1
  32. package/lib/commonjs/mail-thread/shared.js +2 -1
  33. package/lib/commonjs/mail-thread/shared.js.map +1 -1
  34. package/lib/commonjs/page-footer/PageFooter.js +137 -0
  35. package/lib/commonjs/page-footer/PageFooter.js.map +1 -0
  36. package/lib/commonjs/page-footer/PageFooterProvider.js +22 -0
  37. package/lib/commonjs/page-footer/PageFooterProvider.js.map +1 -0
  38. package/lib/commonjs/page-footer/context.js +11 -0
  39. package/lib/commonjs/page-footer/context.js.map +1 -0
  40. package/lib/commonjs/page-footer/index.js +27 -0
  41. package/lib/commonjs/page-footer/index.js.map +1 -0
  42. package/lib/commonjs/page-footer/types.js +6 -0
  43. package/lib/commonjs/page-footer/types.js.map +1 -0
  44. package/lib/commonjs/page-footer/use-page-footer-inset.js +19 -0
  45. package/lib/commonjs/page-footer/use-page-footer-inset.js.map +1 -0
  46. package/lib/commonjs/sidebar/Sidebar.js +6 -5
  47. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  48. package/lib/commonjs/styles/panel-chrome.js +15 -1
  49. package/lib/commonjs/styles/panel-chrome.js.map +1 -1
  50. package/lib/module/app-shell/AppShell.js +3 -2
  51. package/lib/module/app-shell/AppShell.js.map +1 -1
  52. package/lib/module/app-shell/AppShellBars.js +8 -5
  53. package/lib/module/app-shell/AppShellBars.js.map +1 -1
  54. package/lib/module/app-shell/AppShellEngine.js +81 -36
  55. package/lib/module/app-shell/AppShellEngine.js.map +1 -1
  56. package/lib/module/appearance/BloomScope.js +8 -3
  57. package/lib/module/appearance/BloomScope.js.map +1 -1
  58. package/lib/module/appearance/context.js.map +1 -1
  59. package/lib/module/content-panel/ContentPanel.js +35 -24
  60. package/lib/module/content-panel/ContentPanel.js.map +1 -1
  61. package/lib/module/content-panel/ContentPanel.web.js +41 -19
  62. package/lib/module/content-panel/ContentPanel.web.js.map +1 -1
  63. package/lib/module/design-tokens/shapes.js +2 -2
  64. package/lib/module/design-tokens/shapes.js.map +1 -1
  65. package/lib/module/design-tokens/tokens.json +2 -2
  66. package/lib/module/index.js +1 -0
  67. package/lib/module/index.js.map +1 -1
  68. package/lib/module/index.web.js +1 -0
  69. package/lib/module/index.web.js.map +1 -1
  70. package/lib/module/mail-thread/MailMessage.js +8 -14
  71. package/lib/module/mail-thread/MailMessage.js.map +1 -1
  72. package/lib/module/mail-thread/MailThread.js +6 -2
  73. package/lib/module/mail-thread/MailThread.js.map +1 -1
  74. package/lib/module/mail-thread/shared.js +2 -1
  75. package/lib/module/mail-thread/shared.js.map +1 -1
  76. package/lib/module/page-footer/PageFooter.js +132 -0
  77. package/lib/module/page-footer/PageFooter.js.map +1 -0
  78. package/lib/module/page-footer/PageFooterProvider.js +18 -0
  79. package/lib/module/page-footer/PageFooterProvider.js.map +1 -0
  80. package/lib/module/page-footer/context.js +7 -0
  81. package/lib/module/page-footer/context.js.map +1 -0
  82. package/lib/module/page-footer/index.js +6 -0
  83. package/lib/module/page-footer/index.js.map +1 -0
  84. package/lib/module/page-footer/types.js +4 -0
  85. package/lib/module/page-footer/types.js.map +1 -0
  86. package/lib/module/page-footer/use-page-footer-inset.js +16 -0
  87. package/lib/module/page-footer/use-page-footer-inset.js.map +1 -0
  88. package/lib/module/sidebar/Sidebar.js +6 -5
  89. package/lib/module/sidebar/Sidebar.js.map +1 -1
  90. package/lib/module/styles/panel-chrome.js +14 -1
  91. package/lib/module/styles/panel-chrome.js.map +1 -1
  92. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts +4 -0
  93. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/app-shell/AppShellEngine.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/app-shell/types.d.ts +11 -3
  96. package/lib/typescript/commonjs/app-shell/types.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/appearance/BloomScope.d.ts +1 -1
  98. package/lib/typescript/commonjs/appearance/BloomScope.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/appearance/context.d.ts +2 -2
  100. package/lib/typescript/commonjs/appearance/context.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/appearance/types.d.ts +4 -1
  102. package/lib/typescript/commonjs/appearance/types.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts +3 -4
  104. package/lib/typescript/commonjs/content-panel/ContentPanel.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts +1 -1
  106. package/lib/typescript/commonjs/content-panel/ContentPanel.web.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/content-panel/types.d.ts +2 -2
  108. package/lib/typescript/commonjs/design-tokens/shapes.d.ts +2 -2
  109. package/lib/typescript/commonjs/index.d.ts +1 -0
  110. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  112. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/mail-thread/MailMessage.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/mail-thread/MailThread.d.ts +1 -1
  115. package/lib/typescript/commonjs/mail-thread/MailThread.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/mail-thread/shared.d.ts +1 -1
  117. package/lib/typescript/commonjs/mail-thread/shared.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/mail-thread/types.d.ts +2 -0
  119. package/lib/typescript/commonjs/mail-thread/types.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts +7 -0
  121. package/lib/typescript/commonjs/page-footer/PageFooter.d.ts.map +1 -0
  122. package/lib/typescript/commonjs/page-footer/PageFooterProvider.d.ts +4 -0
  123. package/lib/typescript/commonjs/page-footer/PageFooterProvider.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/page-footer/context.d.ts +3 -0
  125. package/lib/typescript/commonjs/page-footer/context.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/page-footer/index.d.ts +5 -0
  127. package/lib/typescript/commonjs/page-footer/index.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/page-footer/types.d.ts +26 -0
  129. package/lib/typescript/commonjs/page-footer/types.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/page-footer/use-page-footer-inset.d.ts +7 -0
  131. package/lib/typescript/commonjs/page-footer/use-page-footer-inset.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/sidebar/types.d.ts +1 -1
  134. package/lib/typescript/commonjs/styles/panel-chrome.d.ts +5 -0
  135. package/lib/typescript/commonjs/styles/panel-chrome.d.ts.map +1 -1
  136. package/lib/typescript/module/app-shell/AppShellBars.d.ts +4 -0
  137. package/lib/typescript/module/app-shell/AppShellBars.d.ts.map +1 -1
  138. package/lib/typescript/module/app-shell/AppShellEngine.d.ts.map +1 -1
  139. package/lib/typescript/module/app-shell/types.d.ts +11 -3
  140. package/lib/typescript/module/app-shell/types.d.ts.map +1 -1
  141. package/lib/typescript/module/appearance/BloomScope.d.ts +1 -1
  142. package/lib/typescript/module/appearance/BloomScope.d.ts.map +1 -1
  143. package/lib/typescript/module/appearance/context.d.ts +2 -2
  144. package/lib/typescript/module/appearance/context.d.ts.map +1 -1
  145. package/lib/typescript/module/appearance/types.d.ts +4 -1
  146. package/lib/typescript/module/appearance/types.d.ts.map +1 -1
  147. package/lib/typescript/module/content-panel/ContentPanel.d.ts +3 -4
  148. package/lib/typescript/module/content-panel/ContentPanel.d.ts.map +1 -1
  149. package/lib/typescript/module/content-panel/ContentPanel.web.d.ts +1 -1
  150. package/lib/typescript/module/content-panel/ContentPanel.web.d.ts.map +1 -1
  151. package/lib/typescript/module/content-panel/types.d.ts +2 -2
  152. package/lib/typescript/module/design-tokens/shapes.d.ts +2 -2
  153. package/lib/typescript/module/index.d.ts +1 -0
  154. package/lib/typescript/module/index.d.ts.map +1 -1
  155. package/lib/typescript/module/index.web.d.ts +1 -0
  156. package/lib/typescript/module/index.web.d.ts.map +1 -1
  157. package/lib/typescript/module/mail-thread/MailMessage.d.ts.map +1 -1
  158. package/lib/typescript/module/mail-thread/MailThread.d.ts +1 -1
  159. package/lib/typescript/module/mail-thread/MailThread.d.ts.map +1 -1
  160. package/lib/typescript/module/mail-thread/shared.d.ts +1 -1
  161. package/lib/typescript/module/mail-thread/shared.d.ts.map +1 -1
  162. package/lib/typescript/module/mail-thread/types.d.ts +2 -0
  163. package/lib/typescript/module/mail-thread/types.d.ts.map +1 -1
  164. package/lib/typescript/module/page-footer/PageFooter.d.ts +7 -0
  165. package/lib/typescript/module/page-footer/PageFooter.d.ts.map +1 -0
  166. package/lib/typescript/module/page-footer/PageFooterProvider.d.ts +4 -0
  167. package/lib/typescript/module/page-footer/PageFooterProvider.d.ts.map +1 -0
  168. package/lib/typescript/module/page-footer/context.d.ts +3 -0
  169. package/lib/typescript/module/page-footer/context.d.ts.map +1 -0
  170. package/lib/typescript/module/page-footer/index.d.ts +5 -0
  171. package/lib/typescript/module/page-footer/index.d.ts.map +1 -0
  172. package/lib/typescript/module/page-footer/types.d.ts +26 -0
  173. package/lib/typescript/module/page-footer/types.d.ts.map +1 -0
  174. package/lib/typescript/module/page-footer/use-page-footer-inset.d.ts +7 -0
  175. package/lib/typescript/module/page-footer/use-page-footer-inset.d.ts.map +1 -0
  176. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  177. package/lib/typescript/module/sidebar/types.d.ts +1 -1
  178. package/lib/typescript/module/styles/panel-chrome.d.ts +5 -0
  179. package/lib/typescript/module/styles/panel-chrome.d.ts.map +1 -1
  180. package/package.json +18 -1
  181. package/src/__tests__/support/adoption-matrix.ts +683 -0
  182. package/src/__tests__/support/card-surface.ts +25 -0
  183. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  184. package/src/__tests__/support/commerce-harness.tsx +97 -0
  185. package/src/__tests__/support/composite.ts +68 -0
  186. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  187. package/src/__tests__/support/messages-in.ts +10 -0
  188. package/src/__tests__/support/press-host.ts +30 -0
  189. package/src/__tests__/support/rendered-style.ts +99 -0
  190. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  191. package/src/__tests__/support/worklet-capture-fixture.tsx +28 -0
  192. package/src/agent-avatar/__tests__/artwork-golden.json +774 -0
  193. package/src/agent-creator/__tests__/support/character-runtime-fixture.tsx +26 -0
  194. package/src/app-shell/AppShell.tsx +2 -2
  195. package/src/app-shell/AppShellBars.tsx +6 -4
  196. package/src/app-shell/AppShellEngine.tsx +51 -18
  197. package/src/app-shell/types.ts +11 -3
  198. package/src/appearance/BloomScope.tsx +5 -2
  199. package/src/appearance/context.ts +2 -2
  200. package/src/appearance/types.ts +4 -1
  201. package/src/content-panel/ContentPanel.tsx +32 -25
  202. package/src/content-panel/ContentPanel.web.tsx +32 -23
  203. package/src/content-panel/types.ts +2 -2
  204. package/src/design-tokens/shapes.ts +1 -1
  205. package/src/design-tokens/tokens.json +2 -2
  206. package/src/index.ts +1 -0
  207. package/src/index.web.ts +1 -0
  208. package/src/mail-thread/MailMessage.tsx +9 -11
  209. package/src/mail-thread/MailThread.tsx +3 -1
  210. package/src/mail-thread/shared.ts +2 -1
  211. package/src/mail-thread/types.ts +2 -0
  212. package/src/page-footer/PageFooter.tsx +96 -0
  213. package/src/page-footer/PageFooterProvider.tsx +9 -0
  214. package/src/page-footer/context.ts +6 -0
  215. package/src/page-footer/index.ts +4 -0
  216. package/src/page-footer/types.ts +26 -0
  217. package/src/page-footer/use-page-footer-inset.ts +17 -0
  218. package/src/sidebar/Sidebar.tsx +5 -4
  219. package/src/sidebar/types.ts +1 -1
  220. package/src/styles/panel-chrome.ts +9 -1
  221. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  222. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  223. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -0,0 +1,26 @@
1
+ import type { ContextType, ReactNode } from 'react';
2
+ import {
3
+ CharacterCapabilitiesContext,
4
+ CharacterRuntimeContext,
5
+ } from '../../../agent-avatar/context';
6
+
7
+ /** Supply both internal channels without starting the browser engine in UI tests. */
8
+ export function CharacterRuntimeFixture({
9
+ value,
10
+ children,
11
+ }: {
12
+ value: ContextType<typeof CharacterRuntimeContext> &
13
+ ContextType<typeof CharacterCapabilitiesContext>;
14
+ children: ReactNode;
15
+ }) {
16
+ const { runtimeUrl, reportCapabilities, ...catalog } = value;
17
+ return (
18
+ <CharacterRuntimeContext.Provider
19
+ value={{ runtimeUrl, reportCapabilities }}
20
+ >
21
+ <CharacterCapabilitiesContext.Provider value={catalog}>
22
+ {children}
23
+ </CharacterCapabilitiesContext.Provider>
24
+ </CharacterRuntimeContext.Provider>
25
+ );
26
+ }
@@ -84,7 +84,7 @@ export function createAppShell(BottomBar: ComponentType<BottomBarProps>, Fab: Co
84
84
  ) : null}
85
85
  <Screen navigationScope="shared" active={active} documentScroll={scroll === 'external'} testID={testID ? `${testID}-screen` : undefined} style={{ minWidth: 0 }} header={header ?? (title ? <PageHeader title={title} subtitle={breadcrumb} actions={actions} sticky={false} /> : null)} bottomBar={bottom} primaryAction={placement !== 'bottom' && primaryAction ? <Fab {...primaryAction} /> : undefined}>
86
86
  {scroll === 'auto' ? <ScreenScrollView style={{ flex: 1 }}>
87
- <View style={{ width: '100%', maxWidth: contentMaxWidth, alignSelf: 'center', padding: 16, gap: 16 }}>{children}</View>
87
+ <View testID={testID ? `${testID}-content` : undefined} style={{ width: '100%', maxWidth: contentMaxWidth === 'none' ? undefined : contentMaxWidth, alignSelf: 'center', padding: 16, gap: 16 }}>{children}</View>
88
88
  </ScreenScrollView> : children}
89
89
  {overlay}
90
90
  </Screen>
@@ -151,7 +151,7 @@ export function createAppShell(BottomBar: ComponentType<BottomBarProps>, Fab: Co
151
151
  || props.primaryAction !== undefined || props.scroll === 'auto' || props.scroll === 'external';
152
152
  if (!adaptive) return <AppShellEngine {...props as AppShellEngineProps} />;
153
153
  const scroll = props.scroll ?? (Platform.OS === 'web' ? 'document' : 'auto');
154
- const usesLayout = props.panel !== undefined || props.panelColorPreset !== undefined || props.variant !== undefined || props.aside !== undefined || props.list !== undefined || props.info !== undefined
154
+ const usesLayout = props.safeArea !== undefined || props.panel !== undefined || props.panelColorPreset !== undefined || props.variant !== undefined || props.aside !== undefined || props.list !== undefined || props.info !== undefined
155
155
  || props.contentWidth !== undefined || props.navigationAlign !== undefined || props.navigationGap !== undefined || props.drawer !== undefined
156
156
  || props.navFrom !== undefined || props.navExpandedFrom !== undefined || props.topBar !== undefined
157
157
  || props.bottomBar !== undefined || props.floatingAction !== undefined;
@@ -36,13 +36,13 @@ import { useClaimBottomEdge } from '../layout/bottom-edge';
36
36
  import { Z_INDEX } from '../styles/z-index';
37
37
 
38
38
  /** The safe-area insets, or zeros outside a `SafeAreaProvider`. */
39
- export function useShellInsets(): { top: number; bottom: number } {
39
+ export function useShellInsets(): { top: number; right: number; bottom: number; left: number } {
40
40
  // `useContext(SafeAreaInsetsContext)` rather than `useSafeAreaInsets()`: the
41
41
  // hook THROWS outside a provider, and `AppShell` is mounted at an app's root,
42
42
  // where making a previously-optional provider mandatory would be a breaking
43
43
  // change for every consumer that never had a bar to inset.
44
44
  const insets = useContext(SafeAreaInsetsContext);
45
- return { top: insets?.top ?? 0, bottom: insets?.bottom ?? 0 };
45
+ return insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
46
46
  }
47
47
 
48
48
  /** Pinned to the bottom of the viewport (document scroll) or of the frame. */
@@ -152,6 +152,8 @@ export const AppShellFloatingAction = memo(AppShellFloatingActionComponent);
152
152
  AppShellFloatingAction.displayName = 'AppShellFloatingAction';
153
153
 
154
154
  export interface AppShellTopBarProps {
155
+ /** The enclosing safe frame may already own the top inset. */
156
+ insetTop?: number;
155
157
  children: React.ReactNode;
156
158
  onHeightChange?: (height: number) => void;
157
159
  doc: boolean;
@@ -167,11 +169,11 @@ export interface AppShellTopBarProps {
167
169
  * only as far as `top: 0`); in `container`/`fixed` and on native it is simply
168
170
  * outside the scroller, which pins it by construction.
169
171
  */
170
- const AppShellTopBarComponent: React.FC<AppShellTopBarProps> = ({ children, doc, testID, onHeightChange }) => {
172
+ const AppShellTopBarComponent: React.FC<AppShellTopBarProps> = ({ children, doc, testID, onHeightChange, insetTop }) => {
171
173
  const insets = useShellInsets();
172
174
  const pinned: WebCssStyle = doc ? { position: WEB_POSITION_STICKY, top: 0 } : {};
173
175
  return (
174
- <View testID={testID} onLayout={event => onHeightChange?.(event.nativeEvent.layout.height)} style={[{ zIndex: Z_INDEX.floating, paddingTop: insets.top }, pinned]}>
176
+ <View testID={testID} onLayout={event => onHeightChange?.(event.nativeEvent.layout.height)} style={[{ zIndex: Z_INDEX.floating, paddingTop: insetTop ?? insets.top }, pinned]}>
175
177
  {children}
176
178
  </View>
177
179
  );
@@ -106,6 +106,7 @@ export function createAppShellEngine(
106
106
  ) {
107
107
  const AppShellComponent: React.FC<AppShellEngineProps> = ({
108
108
  variant = 'dashboard',
109
+ safeArea = false,
109
110
  navigationAlign = 'edge',
110
111
  navigationGap,
111
112
  asideGap,
@@ -166,6 +167,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
166
167
  const [asideHeight, setAsideHeight] = useState(0);
167
168
  const { width, onLayout } = useShellWidth();
168
169
  const insets = useShellInsets();
170
+ const ownsSafeArea = safeArea && Platform.OS !== 'web';
169
171
  const mode = resolveScrollMode(variant, scroll);
170
172
  const doc = mode === 'document';
171
173
  const fixed = mode === 'fixed';
@@ -175,6 +177,10 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
175
177
  // for every other shape.
176
178
  const gutter = gutterProp ?? (variant === 'dashboard' ? D.dashboardGutter : D.gutter);
177
179
  const columnGap = variant === 'dashboard' ? D.dashboardColumnGap : gutter;
180
+ // Historical reveal geometry stays the default; an explicit gutter owns
181
+ // every frame edge, including the old extra top gap at wider breakpoints.
182
+ const revealGutter = gutterProp ?? 12;
183
+ const revealTopGutter = gutterProp ?? (small ? 24 : 12);
178
184
 
179
185
  // The nav: `navExpandedFrom` decides which sidebar variant is drawn, and the
180
186
  // variant decides how early it can sit in flow (a rail is narrow enough at
@@ -290,6 +296,24 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
290
296
  const showTopBar = topBar != null && barActive(topBarVisibility);
291
297
  const showBottomBar = bottomBar != null && barActive(bottomBarVisibility);
292
298
  const showFloatingAction = floatingAction != null;
299
+ // Preserve the real device insets for fullscreen dialogs and portals. Only
300
+ // the bounded shell consumes these edges; bottom chrome keeps its own inset.
301
+ const bottomChrome = showBottomBar || showFloatingAction;
302
+ const frameBottomInset = ownsSafeArea && !bottomChrome ? insets.bottom : 0;
303
+ const navigationBottomInset = ownsSafeArea && bottomChrome ? insets.bottom : 0;
304
+ const safeFrame = (node: React.ReactElement) => ownsSafeArea ? (
305
+ <View {...dirProps} testID={testID ? `${testID}-safe-area` : undefined} style={{
306
+ flex: 1, minHeight: 0, minWidth: 0,
307
+ paddingTop: insets.top,
308
+ paddingInlineStart: rtl ? insets.right : insets.left,
309
+ paddingInlineEnd: rtl ? insets.left : insets.right,
310
+ paddingBottom: frameBottomInset, backgroundColor: background,
311
+ }}>{node}</View>
312
+ ) : node;
313
+
314
+ const safeNavigation = (node: React.ReactElement) => navigationBottomInset ? (
315
+ <View style={{ flexShrink: 0, paddingBottom: navigationBottomInset }}>{node}</View>
316
+ ) : node;
293
317
  const [topBarHeight, setTopBarHeight] = useState(0);
294
318
  const [bottomBarHeight, setBottomBarHeight] = useState(0);
295
319
  const [floatingActionHeight, setFloatingActionHeight] = useState(0);
@@ -328,7 +352,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
328
352
  );
329
353
 
330
354
  const topBarNode = showTopBar ? (
331
- <AppShellTopBar doc={doc} onHeightChange={setTopBarHeight} testID={testID ? `${testID}-top-bar` : undefined}>
355
+ <AppShellTopBar insetTop={ownsSafeArea ? 0 : undefined} doc={doc} onHeightChange={setTopBarHeight} testID={testID ? `${testID}-top-bar` : undefined}>
332
356
  {topBar}
333
357
  </AppShellTopBar>
334
358
  ) : null;
@@ -363,7 +387,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
363
387
 
364
388
  const fill = fixed ? { flex: 1, minHeight: 0 } : null;
365
389
  const column = (
366
- <View style={[{ width: '100%', maxWidth: contentMaxWidth, alignSelf: 'center', gap: 10 }, fill]}>
390
+ <View testID={testID ? `${testID}-content` : undefined} style={[{ width: '100%', maxWidth: contentMaxWidth === 'none' ? undefined : contentMaxWidth, alignSelf: 'center', gap: 10 }, fill]}>
367
391
  {headerNode}
368
392
  <View
369
393
  style={[
@@ -432,7 +456,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
432
456
  const veilStyle = useAnimatedStyle(() => ({ opacity: reveal.value }), [reveal]);
433
457
 
434
458
  if (drawerStyle === 'reveal' && variant === 'dashboard') {
435
- return (
459
+ return safeFrame(
436
460
  <AppShellProvider value={shell}>
437
461
  <View
438
462
  {...dirProps}
@@ -445,20 +469,21 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
445
469
  style={[
446
470
  flowSidebar.surface === 'docked'
447
471
  ? { flexShrink: 0 }
448
- : { paddingTop: 12, paddingBottom: 12, paddingInlineStart: 12, flexShrink: 0 },
472
+ : { paddingTop: revealGutter, paddingBottom: revealGutter, paddingInlineStart: revealGutter, flexShrink: 0 },
449
473
  doc ? stickyRail(0) : null,
450
474
  ]}
451
475
  >
452
- <Sidebar {...flowSidebar} />
476
+ {safeNavigation(<Sidebar {...flowSidebar} />)}
453
477
  </View>
454
478
  ) : null}
455
479
  {drawerAvailable && drawerSidebar ? (
456
480
  <View
481
+ testID={testID ? `${testID}-navigation-drawer` : undefined}
457
482
  aria-hidden={!isOpen}
458
483
  pointerEvents={isOpen ? 'auto' : 'none'}
459
484
  // Fixed on web in document mode: the rail waits beneath the VIEWPORT,
460
485
  // wherever the page is scrolled to.
461
- style={{ position: doc ? WEB_POSITION_FIXED : 'absolute', top: 0, bottom: 0, insetInlineStart: 0, width: 272, paddingTop: 12, paddingBottom: 12, paddingInlineStart: 6 }}
486
+ style={{ position: doc ? WEB_POSITION_FIXED : 'absolute', top: 0, bottom: 0, insetInlineStart: 0, width: 272, paddingTop: revealGutter, paddingBottom: revealGutter + navigationBottomInset, paddingInlineStart: revealGutter / 2 }}
462
487
  >
463
488
  <Animated.View style={[{ height: '100%', width: 260, transformOrigin: railOrigin }, railStyle]}>
464
489
  <Sidebar {...drawerSidebar} mobile surface="plain" onClose={() => setOpen(false)} />
@@ -487,13 +512,13 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
487
512
  // layered over the shorthand, and only when there is one.
488
513
  contentStyle={
489
514
  contentReserve
490
- ? { paddingLeft: 12, paddingRight: 12, paddingTop: small ? 24 : 12, paddingBottom: 12 + contentReserve }
491
- : { padding: 12, paddingTop: small ? 24 : 12 }
515
+ ? { paddingLeft: revealGutter, paddingRight: revealGutter, paddingTop: revealTopGutter, paddingBottom: revealGutter + contentReserve }
516
+ : { padding: revealGutter, paddingTop: revealTopGutter }
492
517
  }
493
518
  >
494
519
  {column}
495
520
  </Scroller>
496
- {asideColumn({ paddingTop: small ? 24 : 12, paddingBottom: 12, paddingInlineEnd: 12 })}
521
+ {asideColumn({ paddingTop: revealTopGutter, paddingBottom: revealGutter, paddingInlineEnd: revealGutter })}
497
522
  {!wide ? (
498
523
  <Animated.View
499
524
  pointerEvents={isOpen ? 'auto' : 'none'}
@@ -758,15 +783,15 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
758
783
  navInFlow && flowSidebar ? (
759
784
  doc ? (
760
785
  <View testID={testID ? `${testID}-navigation` : undefined} style={stickyRail(dockedNav || plainFrame ? 0 : gutter)}>
761
- <Sidebar {...flowSidebar} />
786
+ {safeNavigation(<Sidebar {...flowSidebar} />)}
762
787
  </View>
763
788
  ) : canvas && !dockedNav ? (
764
789
  // The canvas row has no padding of its own, so the nav states its.
765
790
  <View style={{ paddingTop: gutter, paddingBottom: gutter, paddingInlineStart: gutter, flexShrink: 0 }}>
766
- <Sidebar {...flowSidebar} />
791
+ {safeNavigation(<Sidebar {...flowSidebar} />)}
767
792
  </View>
768
793
  ) : (
769
- <Sidebar {...flowSidebar} />
794
+ safeNavigation(<Sidebar {...flowSidebar} />)
770
795
  )
771
796
  ) : null;
772
797
 
@@ -783,7 +808,15 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
783
808
  <View
784
809
  {...dirProps}
785
810
  pointerEvents="box-none"
786
- style={{ position: 'absolute', top: 0, bottom: 0, insetInlineStart: 0, flexDirection: 'row', padding: 12 }}
811
+ testID={testID ? `${testID}-navigation-drawer` : undefined}
812
+ style={{ position: 'absolute', top: 0, bottom: 0, insetInlineStart: 0, flexDirection: 'row',
813
+ ...(ownsSafeArea ? {
814
+ paddingTop: 12 + insets.top,
815
+ paddingBottom: 12 + insets.bottom,
816
+ paddingInlineStart: 12 + (rtl ? insets.right : insets.left),
817
+ paddingInlineEnd: 12 + (rtl ? insets.left : insets.right),
818
+ } : { padding: 12 }),
819
+ }}
787
820
  >
788
821
  <View pointerEvents="auto" style={{ height: '100%' }}>
789
822
  <Sidebar {...drawerSidebar} mobile onClose={() => setOpen(false)} />
@@ -810,12 +843,12 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
810
843
  };
811
844
 
812
845
  if (variant === 'feed' && drawerStyle === 'reveal' && drawerAvailable) {
813
- return (
846
+ return safeFrame(
814
847
  <AppShellProvider value={shell}>
815
848
  <View {...dirProps} testID={testID} onLayout={onLayout}
816
849
  style={[frame, { overflow: doc ? WEB_OVERFLOW_CLIP : 'hidden', backgroundColor: background }, style]}>
817
- <View aria-hidden={!isOpen} pointerEvents={isOpen ? 'auto' : 'none'}
818
- style={{ position: doc ? WEB_POSITION_FIXED : 'absolute', top: 0, bottom: 0, insetInlineStart: 0, width: 272, paddingTop: 12, paddingBottom: 12, paddingInlineStart: 6 }}>
850
+ <View testID={testID ? `${testID}-navigation-drawer` : undefined} aria-hidden={!isOpen} pointerEvents={isOpen ? 'auto' : 'none'}
851
+ style={{ position: doc ? WEB_POSITION_FIXED : 'absolute', top: 0, bottom: 0, insetInlineStart: 0, width: 272, paddingTop: revealGutter, paddingBottom: revealGutter + navigationBottomInset, paddingInlineStart: revealGutter / 2 }}>
819
852
  <Animated.View style={[{ height: '100%', width: 260, transformOrigin: railOrigin }, railStyle]}>
820
853
  {drawerSidebar && <Sidebar {...drawerSidebar} mobile surface="plain" onClose={() => setOpen(false)} />}
821
854
  </Animated.View>
@@ -850,7 +883,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
850
883
  // edge without escaping the frame's padding. Without one the root stays the
851
884
  // row it has always been, so nothing about an existing shell moves.
852
885
  if (topBarNode) {
853
- return (
886
+ return safeFrame(
854
887
  <AppShellProvider value={shell}>
855
888
  <View
856
889
  {...dirProps}
@@ -871,7 +904,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
871
904
  );
872
905
  }
873
906
 
874
- return (
907
+ return safeFrame(
875
908
  <AppShellProvider value={shell}>
876
909
  <View {...dirProps} testID={testID} onLayout={onLayout} style={[frame, rowStyle, style]}>
877
910
  {navRegion}
@@ -67,6 +67,14 @@ export type AppShellScroll = 'document' | 'container' | 'fixed';
67
67
  export type AppShellBarVisibility = 'compact' | 'always';
68
68
 
69
69
  export interface AppShellEngineProps {
70
+ /**
71
+ * Native: keep the shell, navigation and pages inside the device's safe area.
72
+ * The bottom-bar slot still owns its bottom inset; without bottom chrome the
73
+ * frame reserves it. Use `safeArea={false}` on descendant PageHeaders because
74
+ * the frame already owns their top edge. Device insets remain available to
75
+ * dialogs and other portaled surfaces. Defaults to false; no effect on web.
76
+ */
77
+ safeArea?: boolean;
70
78
  /**
71
79
  * The layout shape. Defaults to `dashboard` — the behaviour this component
72
80
  * had before variants existed, down to the pixel.
@@ -124,8 +132,8 @@ export interface AppShellEngineProps {
124
132
  */
125
133
  asideCollapse?: 'stack' | 'hidden';
126
134
  children?: ReactNode;
127
- /** The content column's max width. Defaults to 1300. */
128
- contentMaxWidth?: number;
135
+ /** The content column's max width. Defaults to 1300; `none` fills the available width. */
136
+ contentMaxWidth?: number | 'none';
129
137
  /** Floating extras rendered last, e.g. a `ProOfferCard`. */
130
138
  overlay?: ReactNode;
131
139
  /** Controlled drawer state (below `lg`). */
@@ -364,7 +372,7 @@ export interface AppShellNavigationProps {
364
372
  actions?: ReactNode;
365
373
  header?: ReactNode;
366
374
  children?: ReactNode;
367
- contentMaxWidth?: number;
375
+ contentMaxWidth?: number | 'none';
368
376
  overlay?: ReactNode;
369
377
  style?: StyleProp<ViewStyle>;
370
378
  testID?: string;
@@ -3,8 +3,11 @@ import { BloomAppearanceContext } from './context';
3
3
  import type { BloomScopeProps } from './types';
4
4
 
5
5
  /** A visual environment only: never inherits actions, selection or disabled state. */
6
- export function BloomScope({ children, size, tone }: BloomScopeProps) {
6
+ export function BloomScope({ children, size, tone, panelRadius }: BloomScopeProps) {
7
+ if (panelRadius !== undefined && (!Number.isFinite(panelRadius) || panelRadius < 0)) {
8
+ throw new RangeError('panelRadius must be a finite, non-negative number.');
9
+ }
7
10
  const parent = useContext(BloomAppearanceContext);
8
- const value = useMemo(() => ({ size: size ?? parent.size, tone: tone ?? parent.tone }), [size, tone, parent]);
11
+ const value = useMemo(() => ({ size: size ?? parent.size, tone: tone ?? parent.tone, panelRadius: panelRadius ?? parent.panelRadius }), [size, tone, panelRadius, parent]);
9
12
  return <BloomAppearanceContext.Provider value={value}>{children}</BloomAppearanceContext.Provider>;
10
13
  }
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext } from 'react';
2
- import type { BloomAppearanceProps } from './types';
2
+ import type { BloomAppearanceProps, BloomScopeProps } from './types';
3
3
 
4
- export const BloomAppearanceContext = createContext<BloomAppearanceProps>({});
4
+ export const BloomAppearanceContext = createContext<Omit<BloomScopeProps, 'children'>>({});
5
5
 
6
6
  /** Explicit props override the nearest scope, then the family's defaults. */
7
7
  export function useBloomAppearance(
@@ -8,4 +8,7 @@ export interface BloomAppearanceProps {
8
8
  size?: BloomSize;
9
9
  tone?: BloomTone;
10
10
  }
11
- export type BloomScopeProps = PropsWithChildren<BloomAppearanceProps>;
11
+ export type BloomScopeProps = PropsWithChildren<BloomAppearanceProps & {
12
+ /** Shared corner radius for framed content panels and card sidebars. Defaults to 28. */
13
+ panelRadius?: number;
14
+ }>;
@@ -11,13 +11,12 @@ import { resolveSurfaceMaterial } from '../surface/resolve-surface-material';
11
11
  * On NATIVE none of the WEB overlay machinery applies: there is no document
12
12
  * scroll, no sticky positioning, and no bleed to mask, so the panel is simply a
13
13
  * surface wrapping its content. The `framed` prop still drives whether that
14
- * surface is rounded + bordered, tri-state and resolved with pure NativeWind
15
- * (the breakpoint's `min-*:` variant evaluates against the window width on
16
- * native too — the material follows the same window breakpoint):
14
+ * surface is rounded + bordered, tri-state with responsive NativeWind borders
15
+ * (the material and scoped radius follow the same window breakpoint):
17
16
  *
18
17
  * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
19
18
  * breakpoint (default `768` / Tailwind `md`), rounded + bordered at/above it
20
- * (e.g. `md:rounded-radius-28 md:border md:border-border`).
19
+ * (e.g. `md:border md:border-border`).
21
20
  * - `false` → never framed (plain full-bleed at every size).
22
21
  * - `true` → always rounded + bordered.
23
22
  *
@@ -35,7 +34,8 @@ import React, { memo } from 'react';
35
34
  import { SurfacePaint } from '../surface/SurfacePaint';
36
35
  import { StyleSheet, useWindowDimensions } from 'react-native';
37
36
 
38
- import { useOptionalPanelChrome } from '../styles/panel-chrome';
37
+ import { useOptionalPanelChrome, usePanelShape } from '../styles/panel-chrome';
38
+ import { surfaceStyle as resolveShapeStyle } from '../shapes/surface-style';
39
39
  import { StyledView } from '../styles/styled-primitives';
40
40
  import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill } from '../styles/surface-levels';
41
41
  import {
@@ -63,17 +63,17 @@ export const PANEL_BOTTOM_INSET = 8;
63
63
  * value is the only runtime decision.
64
64
  */
65
65
  const RESPONSIVE_SURFACE: Record<ContentPanelFramedBreakpoint, string> = {
66
- 500: 'min-[500px]:rounded-radius-28 min-[500px]:border min-[500px]:border-border',
67
- 640: 'sm:rounded-radius-28 sm:border sm:border-border',
68
- 768: 'md:rounded-radius-28 md:border md:border-border',
69
- 1024: 'lg:rounded-radius-28 lg:border lg:border-border',
66
+ 500: 'min-[500px]:border min-[500px]:border-border',
67
+ 640: 'sm:border sm:border-border',
68
+ 768: 'md:border md:border-border',
69
+ 1024: 'lg:border lg:border-border',
70
70
  };
71
71
 
72
72
  const RESPONSIVE_CONTENT: Record<ContentPanelFramedBreakpoint, string> = {
73
- 500: 'min-[500px]:overflow-hidden min-[500px]:rounded-radius-28',
74
- 640: 'sm:overflow-hidden sm:rounded-radius-28',
75
- 768: 'md:overflow-hidden md:rounded-radius-28',
76
- 1024: 'lg:overflow-hidden lg:rounded-radius-28',
73
+ 500: 'min-[500px]:overflow-hidden',
74
+ 640: 'sm:overflow-hidden',
75
+ 768: 'md:overflow-hidden',
76
+ 1024: 'lg:overflow-hidden',
77
77
  };
78
78
 
79
79
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
@@ -91,33 +91,38 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
91
91
  }) => {
92
92
  // Dev-only invariant — a ContentPanel must never be nested inside another.
93
93
  useContentPanelNestingGuard();
94
+ const panelShape = usePanelShape();
94
95
  const panelChrome = useOptionalPanelChrome();
95
96
  // What the panel tells its subtree it is painted in (`./shared.ts`).
96
97
  const rawFill = usePanelSurfaceFill(surfaceClassName, surfaceStyle, surfaceColor);
97
98
  const defaultFill = rawFill;
98
99
  const { width } = useWindowDimensions();
99
100
  const parentFill = useOptionalSurfaceFill();
100
- const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom);
101
+ const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
101
102
  const publishedFill = rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
102
- const radius = StyleSheet.flatten(surfaceStyle)?.borderRadius ?? 28;
103
+ const radius = StyleSheet.flatten(surfaceStyle)?.borderRadius ?? panelShape.radius;
104
+ const isFramed = framed ?? width >= framedFrom;
105
+ const geometry = { ...resolveShapeStyle({ curve: panelShape.curve }), borderRadius: isFramed ? radius : 0 };
103
106
 
104
107
  // Tri-state: `undefined` → responsive (breakpoint-gated), `true` → always
105
108
  // framed, `false` → never framed (plain full-bleed). Whole literal class
106
109
  // strings are selected per mode so the Tailwind content-scan over `src/**`
107
- // picks up every `min-*`/`sm:`/`md:`/`lg:` / `rounded-radius-28` token verbatim
110
+ // picks up every `min-*`/`sm:`/`md:`/`lg:` token verbatim
108
111
  // (the breakpoint bundle lives whole in `RESPONSIVE_SURFACE`; `framed ===
109
112
  // true`/`false` ignore `framedFrom` for fixed always/never framing).
110
113
  const surfaceBase =
111
- framed === undefined
112
- ? `flex-1 ${RESPONSIVE_SURFACE[framedFrom]}`
113
- : framed
114
- ? 'flex-1 rounded-radius-28 border border-border'
115
- : 'flex-1';
114
+ chrome === 'none'
115
+ ? 'flex-1'
116
+ : framed === undefined
117
+ ? `flex-1 ${RESPONSIVE_SURFACE[framedFrom]}`
118
+ : framed
119
+ ? 'flex-1 border border-border'
120
+ : 'flex-1';
116
121
  const surfaceClass = [surfaceBase, fill ? 'min-h-0' : '', surfaceClassName ?? (paintsSurface ? 'bg-transparent' : 'bg-card')].filter(Boolean).join(' ');
117
122
  // Native has no overlays: the surface itself carries the edge. `none` drops
118
123
  // both, `border` keeps the class-drawn hairline, `elevated` adds the lift.
119
124
  const chromeStyle =
120
- framed === false || chrome === 'none' || chrome === 'border'
125
+ !isFramed || chrome === 'none' || chrome === 'border'
121
126
  ? null
122
127
  : shadow || panelChrome
123
128
  ? { boxShadow: shadow ?? panelChrome?.shadow }
@@ -127,24 +132,26 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
127
132
  // bottom and a list inside it scrolls within it. What the prop adds here is
128
133
  // `min-h-0`, so a tall child cannot push the panel past the box it was given
129
134
  // — the same automatic-minimum-size trap as on web.
130
- const contentClass = ['flex-1', framed === true ? 'overflow-hidden rounded-radius-28' : framed === undefined ? RESPONSIVE_CONTENT[framedFrom] : '', fill ? 'min-h-0' : '', contentClassName].filter(Boolean).join(' ');
135
+ const contentClass = ['flex-1', framed === true ? 'overflow-hidden' : framed === undefined ? RESPONSIVE_CONTENT[framedFrom] : '', fill ? 'min-h-0' : '', contentClassName].filter(Boolean).join(' ');
131
136
 
132
137
  return (
133
138
  <ContentPanelNestingContext.Provider value={true}>
134
139
  <StyledView
140
+ testID="content-panel-surface"
135
141
  className={surfaceClass}
136
142
  style={[
137
143
  // The surface variable rides the element that carries the fill, so
138
144
  // the two can never disagree. A no-op on native; the provider below
139
145
  // is what answers there. (`styles/surface-levels.ts`.)
140
146
  surfaceFillVars(publishedFill),
147
+ geometry,
141
148
  chromeStyle,
142
149
  surfaceStyle,
143
150
  paintsSurface ? { backgroundColor: 'transparent' } : null,
144
151
  ]}
145
152
  >
146
- {paintsSurface ? <SurfacePaint fill={defaultFill!} radius={radius} /> : null}
147
- <StyledView className={contentClass} style={[{ borderRadius: (framed ?? width >= framedFrom) ? radius : 0 }, contentStyle]}>
153
+ {paintsSurface ? <SurfacePaint fill={defaultFill!} radius={radius} shape={{ curve: panelShape.curve }} /> : null}
154
+ <StyledView testID="content-panel-content" className={contentClass} style={[geometry, contentStyle]}>
148
155
  {/* The panel is a surface: everything inside is sitting on rung 1,
149
156
  painted in the colour this panel actually paints. */}
150
157
  <SurfaceLevelProvider level={1} fill={publishedFill}>
@@ -83,18 +83,19 @@ import { resolveSurfaceMaterial } from '../surface/resolve-surface-material';
83
83
  *
84
84
  * Styling is NativeWind-className-first; the literal class strings below MUST
85
85
  * stay literal so a consumer's Tailwind content-scan over `lib/**` picks them up
86
- * (no dynamic concatenation of the arbitrary `web:[…]` / `rounded-radius-28` /
86
+ * (no dynamic concatenation of the arbitrary `web:[…]` / `rounded-[var(--bloom-panel-radius)]` /
87
87
  * `md:` parts — whole class strings are selected per mode instead).
88
88
  */
89
89
  import React, { memo, useInsertionEffect } from 'react';
90
90
  import { useSurfaceMaterial } from '../surface/use-surface-material.web';
91
- import { useWindowDimensions, type ViewStyle } from 'react-native';
91
+ import { StyleSheet, useWindowDimensions, type ViewStyle } from 'react-native';
92
92
 
93
93
  import { adoptStyleSheet } from '../styles/adopt-style-sheet';
94
94
  import type { WebCssStyle } from '../styles/web-view-style';
95
+ import { surfaceStyle as resolveShapeStyle } from '../shapes/surface-style';
95
96
  import { StyledView } from '../styles/styled-primitives';
96
97
 
97
- import { useOptionalPanelChrome } from '../styles/panel-chrome';
98
+ import { useOptionalPanelChrome, usePanelShape } from '../styles/panel-chrome';
98
99
  import { SurfaceLevelProvider, surfaceFillVars, useOptionalSurfaceFill } from '../styles/surface-levels';
99
100
  import { useTheme } from '../theme/use-theme';
100
101
  import {
@@ -130,23 +131,23 @@ const RESPONSIVE_WEB: Record<
130
131
  { surface: string; content: string; overlayHidden: string }
131
132
  > = {
132
133
  500: {
133
- surface: 'min-[500px]:rounded-radius-28',
134
- content: 'min-[500px]:rounded-radius-28 web:min-[500px]:overflow-x-clip',
134
+ surface: 'min-[500px]:rounded-[var(--bloom-panel-radius)]',
135
+ content: 'min-[500px]:rounded-[var(--bloom-panel-radius)] web:min-[500px]:overflow-x-clip',
135
136
  overlayHidden: 'max-[500px]:hidden',
136
137
  },
137
138
  640: {
138
- surface: 'sm:rounded-radius-28',
139
- content: 'sm:rounded-radius-28 web:sm:overflow-x-clip',
139
+ surface: 'sm:rounded-[var(--bloom-panel-radius)]',
140
+ content: 'sm:rounded-[var(--bloom-panel-radius)] web:sm:overflow-x-clip',
140
141
  overlayHidden: 'max-sm:hidden',
141
142
  },
142
143
  768: {
143
- surface: 'md:rounded-radius-28',
144
- content: 'md:rounded-radius-28 web:md:overflow-x-clip',
144
+ surface: 'md:rounded-[var(--bloom-panel-radius)]',
145
+ content: 'md:rounded-[var(--bloom-panel-radius)] web:md:overflow-x-clip',
145
146
  overlayHidden: 'max-md:hidden',
146
147
  },
147
148
  1024: {
148
- surface: 'lg:rounded-radius-28',
149
- content: 'lg:rounded-radius-28 web:lg:overflow-x-clip',
149
+ surface: 'lg:rounded-[var(--bloom-panel-radius)]',
150
+ content: 'lg:rounded-[var(--bloom-panel-radius)] web:lg:overflow-x-clip',
150
151
  overlayHidden: 'max-lg:hidden',
151
152
  },
152
153
  };
@@ -197,13 +198,18 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
197
198
  // The floating-panel edge the `Sidebar` wears. Optional: the panel drew its
198
199
  // own surface long before it had a shadow, so it must not start throwing
199
200
  // outside a provider.
201
+ const panelShape = usePanelShape();
202
+ const radius = StyleSheet.flatten(surfaceStyle)?.borderRadius ?? panelShape.radius;
203
+ const curveStyle = resolveShapeStyle({ curve: panelShape.curve });
204
+ const panelStyle = { ...curveStyle, borderRadius: radius };
205
+ const panelVars: WebCssStyle = { '--bloom-panel-radius': typeof radius === 'string' ? radius : `${typeof radius === 'number' ? radius : panelShape.radius}px` };
200
206
  const panelChrome = useOptionalPanelChrome();
201
207
  // What the panel tells its subtree it is painted in (`./shared.ts`).
202
208
  const rawFill = usePanelSurfaceFill(surfaceClassName, surfaceStyle, surfaceColor);
203
209
  const defaultFill = rawFill;
204
210
  const { width } = useWindowDimensions();
205
211
  const parentFill = useOptionalSurfaceFill();
206
- const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom);
212
+ const paintsSurface = Boolean(defaultFill) && parentFill !== undefined && (framed ?? width >= framedFrom) && chrome !== 'none';
207
213
  const publishedFill = rawFill && paintsSurface ? resolveSurfaceMaterial({ fill: rawFill, parentFill: parentFill! }).publishedFill : rawFill;
208
214
  const materialStyle = useSurfaceMaterial('[data-bloom-panel-material]', 'bloom-content-panel-material', defaultFill ?? 'transparent');
209
215
 
@@ -223,12 +229,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
223
229
  const surfaceBase = responsive
224
230
  ? `flex-1 ${bp.surface}`
225
231
  : framed
226
- ? 'flex-1 rounded-radius-28'
232
+ ? 'flex-1 rounded-[var(--bloom-panel-radius)]'
227
233
  : 'flex-1';
228
234
  const contentBase = responsive
229
235
  ? `flex-1 ${bp.content}`
230
236
  : framed
231
- ? 'flex-1 rounded-radius-28 web:overflow-x-clip'
237
+ ? 'flex-1 rounded-[var(--bloom-panel-radius)] web:overflow-x-clip'
232
238
  : 'flex-1';
233
239
  // `fill`: the panel takes the height its parent gives it and the CONTENT
234
240
  // scrolls inside. `min-h-0` on both boxes is the whole trick — a flex child's
@@ -300,6 +306,9 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
300
306
  // disagree about what the panel painted.
301
307
  surfaceFillVars(publishedFill),
302
308
  insetVars,
309
+ panelVars,
310
+ curveStyle,
311
+ framed === true ? { borderRadius: radius } : framed === false ? { borderRadius: 0 } : null,
303
312
  surfaceStyle,
304
313
  paintsSurface ? materialStyle : null,
305
314
  ]}
@@ -315,12 +324,12 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
315
324
  pointerEvents="none"
316
325
  className={
317
326
  boundToPanel
318
- ? `web:[grid-area:1/1] z-30 h-full w-full rounded-radius-28 ${responsive ? bp.overlayHidden : ''} web:[clip-path:inset(0)]`
327
+ ? `web:[grid-area:1/1] z-30 h-full w-full rounded-[var(--bloom-panel-radius)] ${responsive ? bp.overlayHidden : ''} web:[clip-path:inset(0)]`
319
328
  : responsive
320
- ? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
321
- : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)]'
329
+ ? `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)]`
330
+ : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-[var(--bloom-panel-radius)] web:[margin-bottom:calc(-100dvh+16px)]'
322
331
  }
323
- style={{ ...insetStyle, boxShadow: `0 0 0 ${maskSpread}px ${maskColor ?? colors.background}` }}
332
+ style={{ ...panelStyle, ...insetStyle, boxShadow: `0 0 0 ${maskSpread}px ${maskColor ?? colors.background}` }}
324
333
  />
325
334
  )}
326
335
  {/* (2) Border-frame overlay — one continuous rounded border, above all.
@@ -333,15 +342,15 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
333
342
  // The shadow rides the SAME element as the hairline, so the lift and
334
343
  // the edge can never disagree about where the panel ends. It paints
335
344
  // outward, into the gutter the page background shows.
336
- style={[insetStyle, chrome === 'elevated' && (shadow || panelChrome)
345
+ style={[panelStyle, insetStyle, chrome === 'elevated' && (shadow || panelChrome)
337
346
  ? { boxShadow: shadow ?? panelChrome?.shadow }
338
347
  : null]}
339
348
  className={
340
349
  boundToPanel
341
- ? `web:[grid-area:1/1] z-[120] h-full w-full rounded-radius-28 border border-border ${responsive ? bp.overlayHidden : ''}`
350
+ ? `web:[grid-area:1/1] z-[120] h-full w-full rounded-[var(--bloom-panel-radius)] border border-border ${responsive ? bp.overlayHidden : ''}`
342
351
  : responsive
343
- ? `web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
344
- : 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border web:[margin-bottom:calc(-100dvh+16px)]'
352
+ ? `web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-[var(--bloom-panel-radius)] border border-border ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
353
+ : 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-[var(--bloom-panel-radius)] border border-border web:[margin-bottom:calc(-100dvh+16px)]'
345
354
  }
346
355
  />
347
356
  )}
@@ -349,7 +358,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
349
358
  in place instead of remounting `{children}` (which would reset feed
350
359
  scroll/virtualizer + refetch on a breakpoint cross). Clipped to the
351
360
  rounded panel shape on web when framed. */}
352
- <StyledView key="content" testID="content-panel-content" className={contentClass} style={contentStyle}>
361
+ <StyledView key="content" testID="content-panel-content" className={contentClass} style={[curveStyle, framed === true ? { borderRadius: radius } : framed === false ? { borderRadius: 0 } : null, contentStyle]}>
353
362
  {/* The panel is a surface: everything inside is sitting on rung 1,
354
363
  painted in the colour this panel actually paints. */}
355
364
  <SurfaceLevelProvider level={1} fill={publishedFill}>
@@ -18,7 +18,7 @@ export type ContentPanelChrome = 'elevated' | 'border' | 'none';
18
18
  export interface ContentPanelProps {
19
19
  children: React.ReactNode;
20
20
  /**
21
- * Framing mode. Tri-state, resolved purely with NativeWind — no consumer
21
+ * Framing mode. Tri-state, managed by Bloom — no consumer
22
22
  * breakpoint hook needed:
23
23
  * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
24
24
  * breakpoint, rounded + bordered at/above it.
@@ -41,7 +41,7 @@ export interface ContentPanelProps {
41
41
  * surface lifted off the page rather than a box drawn on it.
42
42
  * - `'border'` — the hairline alone, which is what this panel drew before.
43
43
  * - `'none'` — no edge at all: a flat surface that still clips and rounds,
44
- * for an app whose page background already separates the two.
44
+ * without material rim or sheen, for an app whose page background separates the two.
45
45
  *
46
46
  * Ignored while the panel is full-bleed (below `framedFrom`, or
47
47
  * `framed={false}`) — there is no edge to draw.