@oxy.so/bloom 4.1.0 → 4.3.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 (93) hide show
  1. package/docs/adoption-matrix.mdx +7 -7
  2. package/docs/ai-chat.mdx +8 -0
  3. package/docs/app-shell.mdx +7 -0
  4. package/docs/bottom-bar.mdx +6 -0
  5. package/docs/settings-modal.mdx +14 -5
  6. package/docs/sidebar.mdx +11 -0
  7. package/lib/commonjs/ai-chat/AiChatContainer.js +8 -8
  8. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  9. package/lib/commonjs/app-shell/AppShellBars.js +8 -2
  10. package/lib/commonjs/app-shell/AppShellBars.js.map +1 -1
  11. package/lib/commonjs/app-shell/AppShellEngine.js +29 -6
  12. package/lib/commonjs/app-shell/AppShellEngine.js.map +1 -1
  13. package/lib/commonjs/bottom-bar/BottomBarBase.js +10 -2
  14. package/lib/commonjs/bottom-bar/BottomBarBase.js.map +1 -1
  15. package/lib/commonjs/layout/bottom-bar-slot.js +10 -0
  16. package/lib/commonjs/layout/bottom-bar-slot.js.map +1 -0
  17. package/lib/commonjs/settings-modal/SettingsModal.js +153 -230
  18. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  19. package/lib/commonjs/sidebar/Sidebar.js +35 -5
  20. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  21. package/lib/module/ai-chat/AiChatContainer.js +8 -8
  22. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  23. package/lib/module/app-shell/AppShellBars.js +8 -2
  24. package/lib/module/app-shell/AppShellBars.js.map +1 -1
  25. package/lib/module/app-shell/AppShellEngine.js +30 -7
  26. package/lib/module/app-shell/AppShellEngine.js.map +1 -1
  27. package/lib/module/bottom-bar/BottomBarBase.js +11 -3
  28. package/lib/module/bottom-bar/BottomBarBase.js.map +1 -1
  29. package/lib/module/layout/bottom-bar-slot.js +7 -0
  30. package/lib/module/layout/bottom-bar-slot.js.map +1 -0
  31. package/lib/module/settings-modal/SettingsModal.js +150 -227
  32. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  33. package/lib/module/sidebar/Sidebar.js +35 -5
  34. package/lib/module/sidebar/Sidebar.js.map +1 -1
  35. package/lib/typescript/commonjs/ai-chat/types.d.ts +2 -2
  36. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts +1 -0
  38. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/app-shell/AppShellEngine.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts +1 -1
  41. package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/bottom-bar/types.d.ts +4 -0
  43. package/lib/typescript/commonjs/bottom-bar/types.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/layout/bottom-bar-slot.d.ts +3 -0
  45. package/lib/typescript/commonjs/layout/bottom-bar-slot.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts +1 -1
  47. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/settings-modal/types.d.ts +7 -2
  49. package/lib/typescript/commonjs/settings-modal/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/sidebar/types.d.ts +12 -1
  52. package/lib/typescript/commonjs/sidebar/types.d.ts.map +1 -1
  53. package/lib/typescript/module/ai-chat/types.d.ts +2 -2
  54. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  55. package/lib/typescript/module/app-shell/AppShellBars.d.ts +1 -0
  56. package/lib/typescript/module/app-shell/AppShellBars.d.ts.map +1 -1
  57. package/lib/typescript/module/app-shell/AppShellEngine.d.ts.map +1 -1
  58. package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts +1 -1
  59. package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts.map +1 -1
  60. package/lib/typescript/module/bottom-bar/types.d.ts +4 -0
  61. package/lib/typescript/module/bottom-bar/types.d.ts.map +1 -1
  62. package/lib/typescript/module/layout/bottom-bar-slot.d.ts +3 -0
  63. package/lib/typescript/module/layout/bottom-bar-slot.d.ts.map +1 -0
  64. package/lib/typescript/module/settings-modal/SettingsModal.d.ts +1 -1
  65. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  66. package/lib/typescript/module/settings-modal/types.d.ts +7 -2
  67. package/lib/typescript/module/settings-modal/types.d.ts.map +1 -1
  68. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  69. package/lib/typescript/module/sidebar/types.d.ts +12 -1
  70. package/lib/typescript/module/sidebar/types.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/support/adoption-matrix.ts +703 -0
  73. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  74. package/src/__tests__/support/commerce-harness.tsx +97 -0
  75. package/src/__tests__/support/composite.ts +68 -0
  76. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  77. package/src/__tests__/support/press-host.ts +30 -0
  78. package/src/__tests__/support/rendered-style.ts +99 -0
  79. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  80. package/src/ai-chat/AiChatContainer.tsx +7 -7
  81. package/src/ai-chat/types.ts +2 -2
  82. package/src/app-shell/AppShellBars.tsx +5 -3
  83. package/src/app-shell/AppShellEngine.tsx +23 -8
  84. package/src/bottom-bar/BottomBarBase.tsx +7 -4
  85. package/src/bottom-bar/types.ts +4 -0
  86. package/src/layout/bottom-bar-slot.ts +4 -0
  87. package/src/settings-modal/SettingsModal.tsx +180 -250
  88. package/src/settings-modal/types.ts +7 -2
  89. package/src/sidebar/Sidebar.tsx +26 -5
  90. package/src/sidebar/types.ts +12 -1
  91. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  92. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  93. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -6,19 +6,12 @@ import React, {
6
6
  useRef,
7
7
  useState,
8
8
  } from 'react';
9
- import {
10
- BackHandler,
11
- Pressable,
12
- ScrollView,
13
- StyleSheet,
14
- View,
15
- useWindowDimensions,
16
- type NativeScrollEvent,
17
- type NativeSyntheticEvent,
18
- } from 'react-native';
9
+ import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
19
10
  import Animated, {
20
11
  Easing,
21
12
  useAnimatedStyle,
13
+ useAnimatedScrollHandler,
14
+ type SharedValue,
22
15
  useReducedMotion,
23
16
  useSharedValue,
24
17
  withTiming,
@@ -26,11 +19,11 @@ import Animated, {
26
19
 
27
20
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
28
21
 
29
- import { RiArrowLeftSLine } from '../icons/remix/RiArrowLeftSLine';
30
22
  import { RiArrowRightSLine } from '../icons/remix/RiArrowRightSLine';
31
23
  import { RiCheckboxCircleFill } from '../icons/remix/RiCheckboxCircleFill';
24
+ import { PageHeader } from '../page-header';
25
+ import { ButtonGroup, ButtonGroupItem } from '../button-group';
32
26
  import { RiCloseLine } from '../icons/remix/RiCloseLine';
33
- import { VerticalFade } from './SettingsArt';
34
27
  import { Backdrop, OverlayRoot } from '../overlay';
35
28
  import type { WebCssStyle } from '../styles/web-view-style';
36
29
  import { webDataSet } from '../styles/web-data';
@@ -58,10 +51,8 @@ import { IS_WEB, useSettingsWebCss } from './web-css';
58
51
  * 20px icon (icon/secondary) + 8 + body-medium label
59
52
  * (selected: background/secondary/hover + text/primary;
60
53
  * hover: background/secondary/hover @ 60%)
61
- * content title row px 32 pt 32 pb 12 (Storage 6), title-3-medium +
62
- * 24px round close (background/tertiary, hover tertiary/hover,
63
- * 16px glyph); the page scrolls under a 40px top fade that eases
64
- * in (200ms) once it is scrolled; px 32 pb 32
54
+ * content one viewport with a sticky PageHeader and its scroll-linked edge;
55
+ * content insets belong to the page body, never the viewport
65
56
  * toast "Saved" pill straddling the panel's bottom edge: 1px
66
57
  * border/button, background/primary, py 4 pr 10 pl 6, gap 4,
67
58
  * 16px lime check + body-2-medium, shadow-dropdown. Rises 12px in,
@@ -88,7 +79,11 @@ const RAIL_WIDTH = 274;
88
79
  /** `medium` layout: the rail narrows so the page keeps a readable measure. */
89
80
  const RAIL_WIDTH_MEDIUM = 220;
90
81
  /** Content inset per layout — 32 at regular, stepping down with the room. */
91
- const CONTENT_INSET: Record<SettingsModalLayout, number> = { regular: 32, medium: 24, compact: 16 };
82
+ const CONTENT_INSET: Record<SettingsModalLayout, number> = {
83
+ regular: 32,
84
+ medium: 24,
85
+ compact: 16,
86
+ };
92
87
  const VIEWPORT_GUTTER = 16;
93
88
  const MOTION_MS = 300;
94
89
  const UNMOUNT_MS = 320;
@@ -128,11 +123,13 @@ export function SettingsModal({
128
123
  control,
129
124
  open: controlledOpen,
130
125
  onClose,
126
+ onBeforeLeave,
131
127
  groups,
132
128
  pages,
133
129
  page: controlledPage,
134
130
  defaultPage,
135
131
  onPageChange,
132
+ initialView = 'navigation',
136
133
  labels,
137
134
  testID,
138
135
  }: SettingsModalProps) {
@@ -156,7 +153,14 @@ export function SettingsModal({
156
153
  const insets = useSafeAreaInsets();
157
154
 
158
155
  // `compact` layout is two levels: the section list, then a page pushed over it.
159
- const [compactPageOpen, setCompactPageOpen] = useState(false);
156
+ const [compactPageOpen, setCompactPageOpen] = useState(initialView === 'page');
157
+ const initialViewRef = useRef(initialView);
158
+ initialViewRef.current = initialView;
159
+
160
+ const beforeLeaveRef = useRef(onBeforeLeave);
161
+ beforeLeaveRef.current = onBeforeLeave;
162
+ const mayLeave = useCallback((reason: 'close' | 'navigation' | 'page') =>
163
+ beforeLeaveRef.current?.(reason) !== false, []);
160
164
 
161
165
  const onCloseRef = useRef(onClose);
162
166
  onCloseRef.current = onClose;
@@ -167,7 +171,7 @@ export function SettingsModal({
167
171
 
168
172
  const show = useCallback(() => {
169
173
  setInternalPage(defaultPageRef.current);
170
- setCompactPageOpen(false);
174
+ setCompactPageOpen(initialViewRef.current === 'page');
171
175
  setMounted(true);
172
176
  setOpenCount((count) => count + 1);
173
177
  }, []);
@@ -210,31 +214,37 @@ export function SettingsModal({
210
214
  }, [isControlled, controlledOpen, show, hide]);
211
215
 
212
216
  const requestClose = useCallback(() => {
217
+ if (!mayLeave('close')) return false;
213
218
  if (isControlledRef.current) onCloseRef.current?.();
214
219
  else hide();
215
- }, [hide]);
220
+ return true;
221
+ }, [hide, mayLeave]);
216
222
 
217
223
  useImperativeHandle(
218
224
  control?.ref,
219
225
  () => ({
220
226
  open: show,
221
227
  close: (cb?: () => void) => {
222
- hide();
223
- if (cb) setTimeout(cb, UNMOUNT_MS);
228
+ if (requestClose() && cb) setTimeout(cb, UNMOUNT_MS);
224
229
  },
225
230
  }),
226
- [show, hide],
231
+ [show, requestClose],
227
232
  );
228
233
 
229
234
  const selectPage = useCallback(
230
235
  (next: string) => {
236
+ if (next !== currentPage && !mayLeave('page')) return;
231
237
  if (controlledPage === undefined) setInternalPage(next);
232
238
  setCompactPageOpen(true);
233
239
  onPageChange?.(next);
234
240
  },
235
- [controlledPage, onPageChange],
241
+ [controlledPage, currentPage, onPageChange, mayLeave],
236
242
  );
237
243
 
244
+ const requestNavigation = useCallback(() => {
245
+ if (mayLeave('navigation')) setCompactPageOpen(false);
246
+ }, [mayLeave]);
247
+
238
248
  // Escape closes (web). On `window`, not `document`: a menu or popover opened
239
249
  // inside the modal handles Escape on `document` and stops propagation there,
240
250
  // so the innermost surface closes first and the modal stays open.
@@ -259,12 +269,12 @@ export function SettingsModal({
259
269
  if (!mounted || IS_WEB || typeof BackHandler?.addEventListener !== 'function') return;
260
270
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
261
271
  // On a pushed page (compact), back returns to the section list first.
262
- if (compactPageOpenRef.current) setCompactPageOpen(false);
272
+ if (compactPageOpenRef.current) requestNavigation();
263
273
  else requestClose();
264
274
  return true;
265
275
  });
266
276
  return () => sub.remove();
267
- }, [mounted, requestClose]);
277
+ }, [mounted, requestClose, requestNavigation]);
268
278
 
269
279
  // ----- saved toast -------------------------------------------------------
270
280
  const [savedPhase, setSavedPhase] = useState<SavedPhase>('hidden');
@@ -326,9 +336,7 @@ export function SettingsModal({
326
336
  // Full screen, a .85 scale reads as the page shrinking away; compact rises 16px instead.
327
337
  const webPanelMotion: WebCssStyle = {
328
338
  opacity: visible ? 1 : 0,
329
- transform: isCompact
330
- ? [{ translateY: visible ? 0 : 16 }]
331
- : [{ scale: visible ? 1 : 0.85 }],
339
+ transform: isCompact ? [{ translateY: visible ? 0 : 16 }] : [{ scale: visible ? 1 : 0.85 }],
332
340
  filter: visible ? 'blur(0px)' : 'blur(4px)',
333
341
  transitionProperty: 'opacity, transform, filter',
334
342
  transitionDuration: reducedMotion ? '0ms' : `${MOTION_MS}ms`,
@@ -398,16 +406,14 @@ export function SettingsModal({
398
406
  {layout === 'compact' ? (
399
407
  compactPageOpen && pageConfig ? (
400
408
  <View style={styles.content}>
401
- <CompactHeader
402
- title={pageConfig.title}
403
- onBack={() => setCompactPageOpen(false)}
404
- backLabel={labels?.back ?? 'Back'}
405
- closeLabel={closeLabel}
406
- onClose={requestClose}
407
- palette={palette}
408
- testID={testID}
409
- />
410
409
  <PageScroller
410
+ header={{
411
+ title: pageConfig.title,
412
+ onBack: requestNavigation,
413
+ backLabel: labels?.back ?? 'Back',
414
+ closeLabel,
415
+ onClose: requestClose,
416
+ }}
411
417
  key={currentPage}
412
418
  palette={palette}
413
419
  inset={CONTENT_INSET.compact}
@@ -419,14 +425,12 @@ export function SettingsModal({
419
425
  </View>
420
426
  ) : (
421
427
  <View style={styles.content}>
422
- <CompactHeader
423
- title={labels?.dialog ?? 'Settings'}
424
- closeLabel={closeLabel}
425
- onClose={requestClose}
426
- palette={palette}
427
- testID={testID}
428
- />
429
428
  <SettingsRail
429
+ header={{
430
+ title: labels?.dialog ?? 'Settings',
431
+ closeLabel,
432
+ onClose: requestClose,
433
+ }}
430
434
  groups={groups}
431
435
  page={currentPage}
432
436
  onSelect={selectPage}
@@ -449,36 +453,16 @@ export function SettingsModal({
449
453
  testID={testID}
450
454
  />
451
455
  <View style={styles.content}>
452
- <View
453
- style={[
454
- styles.titleRow,
455
- {
456
- paddingLeft: CONTENT_INSET[layout],
457
- paddingRight: CONTENT_INSET[layout],
458
- paddingTop: CONTENT_INSET[layout],
459
- paddingBottom: pageConfig?.compactTitle ? 6 : 12,
460
- },
461
- ]}
462
- testID={testID ? `${testID}-title-row` : undefined}
463
- >
464
- <Text
465
- variant="title-3-medium"
466
- role="heading"
467
- style={{ color: palette.text }}
468
- >
469
- {pageConfig?.title ?? ''}
470
- </Text>
471
- <SettingsCloseButton
472
- label={closeLabel}
473
- onPress={requestClose}
474
- palette={palette}
475
- testID={testID ? `${testID}-close` : undefined}
476
- />
477
- </View>
478
456
  <PageScroller
457
+ header={{
458
+ title: pageConfig?.title ?? '',
459
+ closeLabel,
460
+ onClose: requestClose,
461
+ }}
479
462
  key={currentPage}
480
463
  palette={palette}
481
464
  inset={CONTENT_INSET[layout]}
465
+ insetTop={pageConfig?.compactTitle ? 6 : 12}
482
466
  testID={testID}
483
467
  >
484
468
  {pageConfig?.content}
@@ -505,7 +489,10 @@ export function SettingsModal({
505
489
 
506
490
  function ringVars(palette: SettingsPalette): WebCssStyle | null {
507
491
  return IS_WEB
508
- ? { '--bloom-settings-ring': palette.ring, '--bloom-settings-ring-offset': palette.full }
492
+ ? {
493
+ '--bloom-settings-ring': palette.ring,
494
+ '--bloom-settings-ring-offset': palette.full,
495
+ }
509
496
  : null;
510
497
  }
511
498
 
@@ -514,6 +501,7 @@ function ringVars(palette: SettingsPalette): WebCssStyle | null {
514
501
  // ---------------------------------------------------------------------------
515
502
 
516
503
  function SettingsRail({
504
+ header,
517
505
  groups,
518
506
  page,
519
507
  onSelect,
@@ -522,6 +510,7 @@ function SettingsRail({
522
510
  layout,
523
511
  testID,
524
512
  }: {
513
+ header?: SettingsHeaderProps;
525
514
  groups: SettingsNavGroup[];
526
515
  page: string | undefined;
527
516
  onSelect: (page: string) => void;
@@ -531,42 +520,68 @@ function SettingsRail({
531
520
  testID?: string;
532
521
  }) {
533
522
  const compact = layout === 'compact';
523
+ const scrollY = useSharedValue(0);
524
+ const onScroll = useAnimatedScrollHandler((event) => {
525
+ scrollY.value = Math.max(0, event.contentOffset.y);
526
+ }, [scrollY]);
534
527
  return (
535
- <ScrollView
528
+ <Animated.ScrollView
536
529
  role="navigation"
537
530
  aria-label={label}
538
531
  style={[
539
532
  compact
540
533
  ? styles.railCompact
541
534
  : [styles.rail, { width: layout === 'medium' ? RAIL_WIDTH_MEDIUM : RAIL_WIDTH }],
542
- { backgroundColor: palette.secondary, borderRightColor: palette.separator },
535
+ {
536
+ backgroundColor: palette.secondary,
537
+ borderRightColor: palette.separator,
538
+ },
543
539
  ]}
544
- contentContainerStyle={[styles.railContent, compact ? styles.railContentCompact : null]}
540
+ contentContainerStyle={header ? undefined : styles.railContent}
541
+ stickyHeaderIndices={header && !IS_WEB ? [0] : undefined}
542
+ onScroll={onScroll}
543
+ scrollEventThrottle={16}
544
+ contentInsetAdjustmentBehavior="never"
545
+ automaticallyAdjustContentInsets={false}
545
546
  showsVerticalScrollIndicator={false}
546
547
  testID={testID ? `${testID}-rail` : undefined}
547
548
  >
548
- {groups.map((group) => (
549
- <View key={group.key ?? group.label} style={styles.group}>
550
- <Text variant="body-medium" style={[styles.groupLabel, { color: palette.textSecondary }]}>
551
- {group.label}
552
- </Text>
553
- <View style={styles.groupRows}>
554
- {group.items.map((item) => (
555
- <RailRow
556
- key={item.key}
557
- item={item}
558
- // A list, not a selection, when the rows ARE the navigation.
559
- selected={!compact && item.page !== undefined && item.page === page}
560
- showChevron={compact && item.page !== undefined}
561
- onSelect={onSelect}
562
- palette={palette}
563
- testID={testID ? `${testID}-nav-${item.key}` : undefined}
564
- />
565
- ))}
549
+ {header ? (
550
+ <SettingsHeader
551
+ {...header}
552
+ palette={palette}
553
+ scrimColor={palette.secondary}
554
+ scrollY={scrollY}
555
+ testID={testID}
556
+ />
557
+ ) : null}
558
+ <View style={header ? [styles.railContent, styles.railContentCompact] : { gap: 20 }}>
559
+ {groups.map((group) => (
560
+ <View key={group.key ?? group.label} style={styles.group}>
561
+ <Text
562
+ variant="body-medium"
563
+ style={[styles.groupLabel, { color: palette.textSecondary }]}
564
+ >
565
+ {group.label}
566
+ </Text>
567
+ <View style={styles.groupRows}>
568
+ {group.items.map((item) => (
569
+ <RailRow
570
+ key={item.key}
571
+ item={item}
572
+ // A list, not a selection, when the rows ARE the navigation.
573
+ selected={!compact && item.page !== undefined && item.page === page}
574
+ showChevron={compact && item.page !== undefined}
575
+ onSelect={onSelect}
576
+ palette={palette}
577
+ testID={testID ? `${testID}-nav-${item.key}` : undefined}
578
+ />
579
+ ))}
580
+ </View>
566
581
  </View>
567
- </View>
568
- ))}
569
- </ScrollView>
582
+ ))}
583
+ </View>
584
+ </Animated.ScrollView>
570
585
  );
571
586
  }
572
587
 
@@ -587,9 +602,7 @@ function RailRow({
587
602
  }) {
588
603
  const [hovered, setHovered] = useState(false);
589
604
  const Icon = item.icon;
590
- const onPress = item.page
591
- ? () => onSelect(item.page as string)
592
- : item.onPress;
605
+ const onPress = item.page ? () => onSelect(item.page as string) : item.onPress;
593
606
  return (
594
607
  <Pressable
595
608
  role="button"
@@ -620,7 +633,9 @@ function RailRow({
620
633
  numberOfLines={1}
621
634
  style={[
622
635
  styles.railLabel,
623
- { color: selected || showChevron ? palette.text : palette.textSecondary },
636
+ {
637
+ color: selected || showChevron ? palette.text : palette.textSecondary,
638
+ },
624
639
  ]}
625
640
  >
626
641
  {item.label}
@@ -638,142 +653,102 @@ function RailRow({
638
653
  // Close button, scroller, toast
639
654
  // ---------------------------------------------------------------------------
640
655
 
641
- function SettingsCloseButton({
642
- label,
643
- onPress,
644
- palette,
645
- testID,
646
- }: {
647
- label: string;
648
- onPress: () => void;
649
- palette: SettingsPalette;
650
- testID?: string;
651
- }) {
652
- const [hovered, setHovered] = useState(false);
653
- return (
654
- <Pressable
655
- role="button"
656
- accessibilityLabel={label}
657
- {...webDataSet({ bloomSettingsPress: '' })}
658
- onPress={onPress}
659
- onHoverIn={() => setHovered(true)}
660
- onHoverOut={() => setHovered(false)}
661
- testID={testID}
662
- style={[
663
- styles.close,
664
- { backgroundColor: hovered ? palette.tertiaryHover : palette.tertiary },
665
- ]}
666
- >
667
- <RiCloseLine width={16} height={16} fill={palette.iconSecondary} />
668
- </Pressable>
669
- );
670
- }
656
+ /** Shared header on every layout; the modal already owns safe-area insets. */
657
+ type SettingsHeaderProps = {
658
+ title: string;
659
+ onBack?: () => void;
660
+ backLabel?: string;
661
+ closeLabel: string;
662
+ onClose: () => void;
663
+ };
671
664
 
672
- /**
673
- * The `compact` title bar: optional back chevron, the title, the close button —
674
- * 56px, the page's 16px inset, hairline under it.
675
- */
676
- function CompactHeader({
665
+ function SettingsHeader({
677
666
  title,
678
667
  onBack,
679
668
  backLabel,
680
669
  closeLabel,
681
670
  onClose,
682
671
  palette,
672
+ scrollY,
673
+ scrimColor = palette.full,
683
674
  testID,
684
- }: {
685
- title: string;
686
- onBack?: () => void;
687
- backLabel?: string;
688
- closeLabel: string;
689
- onClose: () => void;
675
+ }: SettingsHeaderProps & {
690
676
  palette: SettingsPalette;
677
+ scrollY: SharedValue<number>;
678
+ scrimColor?: string;
691
679
  testID?: string;
692
680
  }) {
693
681
  return (
694
- <View
695
- style={[styles.compactHeader, { borderBottomColor: palette.separator }]}
696
- testID={testID ? `${testID}-title-row` : undefined}
697
- >
698
- {onBack ? (
699
- <Pressable
700
- role="button"
701
- accessibilityLabel={backLabel}
702
- {...webDataSet({ bloomSettingsPress: '' })}
703
- onPress={onBack}
704
- hitSlop={8}
705
- testID={testID ? `${testID}-back` : undefined}
706
- style={styles.compactBack}
707
- >
708
- <RiArrowLeftSLine width={24} height={24} fill={palette.text} />
709
- </Pressable>
710
- ) : null}
711
- <Text
712
- variant="headline-medium"
713
- role="heading"
714
- numberOfLines={1}
715
- style={[styles.compactTitle, { color: palette.text }]}
716
- >
717
- {title}
718
- </Text>
719
- <SettingsCloseButton
720
- label={closeLabel}
721
- onPress={onClose}
722
- palette={palette}
723
- testID={testID ? `${testID}-close` : undefined}
724
- />
725
- </View>
682
+ <PageHeader
683
+ title={title}
684
+ headingLevel={2}
685
+ onBack={onBack}
686
+ backLabel={backLabel}
687
+ safeArea={false}
688
+ sticky={IS_WEB}
689
+ placement="inline"
690
+ scrim="auto"
691
+ scrimColor={scrimColor}
692
+ scrollY={scrollY}
693
+ actions={
694
+ <ButtonGroup accessibilityLabel={closeLabel}>
695
+ <ButtonGroupItem
696
+ iconOnly
697
+ leadingIcon={RiCloseLine}
698
+ accessibilityLabel={closeLabel}
699
+ onPress={onClose}
700
+ testID={testID ? `${testID}-close` : undefined}
701
+ />
702
+ </ButtonGroup>
703
+ }
704
+ testID={testID ? `${testID}-header` : undefined}
705
+ />
726
706
  );
727
707
  }
728
708
 
709
+ /** Each page owns one full-height viewport, including its header. */
729
710
  function PageScroller({
730
711
  children,
712
+ header,
731
713
  palette,
732
714
  inset,
733
715
  insetTop = 0,
734
716
  testID,
735
717
  }: {
736
718
  children: React.ReactNode;
719
+ header: SettingsHeaderProps;
737
720
  palette: SettingsPalette;
738
721
  inset: number;
739
- /** Top inset — only the compact page, which has no title row above its content. */
722
+ /** Body spacing below the header; compact-title pages keep their smaller gap. */
740
723
  insetTop?: number;
741
724
  testID?: string;
742
725
  }) {
743
- const [scrolled, setScrolled] = useState(false);
744
- const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
745
- const next = event.nativeEvent.contentOffset.y > 0;
746
- setScrolled((prev) => (prev === next ? prev : next));
747
- }, []);
748
- const fade: WebCssStyle = {
749
- opacity: scrolled ? 1 : 0,
750
- ...(IS_WEB
751
- ? { transitionProperty: 'opacity', transitionDuration: '200ms', transitionTimingFunction: 'ease-out' }
752
- : null),
753
- };
726
+ const scrollY = useSharedValue(0);
727
+ const onScroll = useAnimatedScrollHandler((event) => {
728
+ scrollY.value = Math.max(0, event.contentOffset.y);
729
+ }, [scrollY]);
754
730
  return (
755
- <View style={styles.scrollHost}>
756
- <ScrollView
757
- style={styles.scroll}
758
- contentContainerStyle={[
759
- styles.scrollContent,
760
- { paddingLeft: inset, paddingRight: inset, paddingBottom: inset, paddingTop: insetTop },
761
- ]}
762
- onScroll={onScroll}
763
- scrollEventThrottle={16}
764
- testID={testID ? `${testID}-page` : undefined}
765
- >
766
- {children}
767
- </ScrollView>
768
- {/* Progressive top fade — `from-background-primary-default`. */}
731
+ <Animated.ScrollView
732
+ style={styles.scroll}
733
+ stickyHeaderIndices={IS_WEB ? undefined : [0]}
734
+ onScroll={onScroll}
735
+ scrollEventThrottle={16}
736
+ contentInsetAdjustmentBehavior="never"
737
+ automaticallyAdjustContentInsets={false}
738
+ testID={testID ? `${testID}-page` : undefined}
739
+ >
740
+ <SettingsHeader {...header} palette={palette} scrollY={scrollY} testID={testID} />
769
741
  <View
770
- pointerEvents="none"
771
- style={[styles.fade, fade]}
772
- testID={testID ? `${testID}-fade` : undefined}
742
+ style={{
743
+ paddingLeft: inset,
744
+ paddingRight: inset,
745
+ paddingBottom: inset,
746
+ paddingTop: insetTop,
747
+ }}
773
748
  >
774
- <VerticalFade color={palette.primary} />
749
+ {children}
775
750
  </View>
776
- </View>
751
+ </Animated.ScrollView>
777
752
  );
778
753
  }
779
754
 
@@ -865,27 +840,6 @@ const styles = StyleSheet.create({
865
840
  railChevron: {
866
841
  marginLeft: 'auto',
867
842
  },
868
- compactHeader: {
869
- flexDirection: 'row',
870
- alignItems: 'center',
871
- gap: 8,
872
- height: 56,
873
- paddingLeft: 16,
874
- paddingRight: 16,
875
- borderBottomWidth: 1,
876
- },
877
- compactBack: {
878
- width: 32,
879
- height: 32,
880
- marginLeft: -8,
881
- alignItems: 'center',
882
- justifyContent: 'center',
883
- borderRadius: 9999,
884
- },
885
- compactTitle: {
886
- flex: 1,
887
- minWidth: 0,
888
- },
889
843
  panel: {
890
844
  flexDirection: 'row',
891
845
  borderRadius: 24,
@@ -924,33 +878,9 @@ const styles = StyleSheet.create({
924
878
  flex: 1,
925
879
  minWidth: 0,
926
880
  },
927
- titleRow: {
928
- flexDirection: 'row',
929
- alignItems: 'center',
930
- justifyContent: 'space-between',
931
- },
932
- close: {
933
- width: 24,
934
- height: 24,
935
- borderRadius: 9999,
936
- alignItems: 'center',
937
- justifyContent: 'center',
938
- },
939
- scrollHost: {
940
- flex: 1,
941
- minHeight: 0,
942
- position: 'relative',
943
- },
944
881
  scroll: {
945
882
  flex: 1,
946
- },
947
- scrollContent: {},
948
- fade: {
949
- position: 'absolute',
950
- left: 0,
951
- right: 0,
952
- top: 0,
953
- height: 40,
883
+ minHeight: 0,
954
884
  },
955
885
  toast: {
956
886
  position: 'absolute',