@oxyhq/bloom 0.49.0 → 0.50.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 (37) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  2. package/lib/commonjs/dialog/Dialog.js +5 -0
  3. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  4. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  5. package/lib/commonjs/dialog/DialogHeader.js +248 -31
  6. package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
  7. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  8. package/lib/module/dialog/Dialog.js +5 -0
  9. package/lib/module/dialog/Dialog.js.map +1 -1
  10. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  11. package/lib/module/dialog/DialogHeader.js +245 -28
  12. package/lib/module/dialog/DialogHeader.js.map +1 -1
  13. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +2 -2
  14. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +6 -0
  18. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/dialog/DialogMorph.d.ts +2 -2
  20. package/lib/typescript/commonjs/dialog/types.d.ts +67 -0
  21. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  22. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +2 -2
  23. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  24. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  25. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  26. package/lib/typescript/module/dialog/DialogHeader.d.ts +6 -0
  27. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -1
  28. package/lib/typescript/module/dialog/DialogMorph.d.ts +2 -2
  29. package/lib/typescript/module/dialog/types.d.ts +67 -0
  30. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/__tests__/Dialog.test.tsx +77 -0
  33. package/src/bottom-sheet/BottomSheetBase.tsx +2 -1
  34. package/src/dialog/Dialog.tsx +6 -1
  35. package/src/dialog/DialogBottomSheet.tsx +2 -1
  36. package/src/dialog/DialogHeader.tsx +301 -27
  37. package/src/dialog/types.ts +81 -0
@@ -23,9 +23,20 @@ import Animated, {
23
23
 
24
24
  import {
25
25
  ChevronLeft_Stroke2_Corner0_Rounded,
26
+ DotGrid3x1_Stroke2_Corner0_Rounded,
26
27
  TimesLarge_Stroke2_Corner0_Rounded,
27
28
  } from '../icons';
28
29
  import { FrostedIconButton } from '../frosted-icon-button';
30
+ import { Button } from '../button';
31
+ import { Search } from '../search';
32
+ import {
33
+ SegmentedControl,
34
+ SegmentedControlItem,
35
+ SegmentedControlItemText,
36
+ } from '../segmented-control';
37
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
38
+ import { Item } from '../item';
39
+ import { useDialogControl } from './context';
29
40
  import { useTheme } from '../theme/use-theme';
30
41
  import { H1, Text } from '../typography';
31
42
  import type { DialogHeaderConfig } from './types';
@@ -68,6 +79,17 @@ const HEADER_H_PADDING = 20;
68
79
  /** Circular icon-button diameter for the default back / close affordances. */
69
80
  const NAV_BUTTON_SIZE = 36;
70
81
 
82
+ /**
83
+ * Trailing icon `actions` shown inline before they collapse into a "more"
84
+ * overflow menu (Material overflow pattern). Kept small so the nav row never
85
+ * crowds the title.
86
+ */
87
+ export const DIALOG_HEADER_MAX_INLINE_ACTIONS = 3;
88
+
89
+ /** Light content color for `tone: 'onImage'` (legible over any media). */
90
+ const ON_IMAGE_TEXT = '#FFFFFF';
91
+ const ON_IMAGE_SUBTEXT = 'rgba(255,255,255,0.82)';
92
+
71
93
  // --- Runtime override store (child screen → nav bar / large title) ----------
72
94
  //
73
95
  // The nav bar overlay and the in-content large title live in DIFFERENT subtrees
@@ -95,7 +117,15 @@ function configsEqual(a: DialogHeaderConfig | null, b: DialogHeaderConfig | null
95
117
  a.left === b.left &&
96
118
  a.right === b.right &&
97
119
  a.onBack === b.onBack &&
98
- a.showClose === b.showClose
120
+ a.showClose === b.showClose &&
121
+ // Rich fields — object fields compared by identity (callers memoize them);
122
+ // `tone` is a plain value.
123
+ a.primaryAction === b.primaryAction &&
124
+ a.actions === b.actions &&
125
+ a.search === b.search &&
126
+ a.segments === b.segments &&
127
+ a.tone === b.tone &&
128
+ a.progress === b.progress
99
129
  );
100
130
  }
101
131
 
@@ -189,6 +219,12 @@ export function useDialogHeader(config: DialogHeaderConfig | null | undefined):
189
219
  config?.right,
190
220
  config?.onBack,
191
221
  config?.showClose,
222
+ config?.primaryAction,
223
+ config?.actions,
224
+ config?.search,
225
+ config?.segments,
226
+ config?.tone,
227
+ config?.progress,
192
228
  ]);
193
229
  }
194
230
 
@@ -201,6 +237,125 @@ function useMergedHeaderConfig(
201
237
  return useMemo(() => (override ? { ...base, ...override } : base), [base, override]);
202
238
  }
203
239
 
240
+ // --- Rich header sub-parts --------------------------------------------------
241
+
242
+ /**
243
+ * The "more" overflow: a Bloom `Popover` that DROPS from the ⋯ trigger — anchored
244
+ * under the button on web (measured trigger + `bottom-end` placement) and a bottom
245
+ * sheet on native, matching how top libraries present a toolbar overflow (Radix
246
+ * DropdownMenu / Material Menu on desktop, an action sheet on mobile). Items are
247
+ * Bloom `Item` rows; pressing one closes the popover before firing.
248
+ */
249
+ function HeaderOverflowMenu({
250
+ items,
251
+ onImage,
252
+ }: {
253
+ items: NonNullable<DialogHeaderConfig['actions']>;
254
+ onImage: boolean;
255
+ }): React.ReactElement {
256
+ // Own the control so an item press can close the popover before acting.
257
+ const control = useDialogControl();
258
+ return (
259
+ <Popover control={control}>
260
+ <PopoverTrigger label="More">
261
+ {({ props }) => (
262
+ <FrostedIconButton
263
+ size="sm"
264
+ onPress={props.onPress}
265
+ accessibilityLabel={props.accessibilityLabel}
266
+ icon={
267
+ <DotGrid3x1_Stroke2_Corner0_Rounded
268
+ size="md"
269
+ fill={onImage ? ON_IMAGE_TEXT : undefined}
270
+ />
271
+ }
272
+ />
273
+ )}
274
+ </PopoverTrigger>
275
+ <PopoverContent label="More actions" placement="bottom-end">
276
+ {items.map((action) => (
277
+ <Item
278
+ key={action.accessibilityLabel}
279
+ title={action.accessibilityLabel}
280
+ leading={action.icon}
281
+ density="compact"
282
+ disabled={action.disabled}
283
+ onPress={() => control.close(() => action.onPress())}
284
+ />
285
+ ))}
286
+ </PopoverContent>
287
+ </Popover>
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Trailing icon `actions`: the first {@link DIALOG_HEADER_MAX_INLINE_ACTIONS}
293
+ * render inline as frosted circles; any surplus collapses into a "more" overflow
294
+ * that drops from the ⋯ ({@link HeaderOverflowMenu}) — never hand-rolled.
295
+ */
296
+ function HeaderTrailingActions({
297
+ actions,
298
+ onImage,
299
+ }: {
300
+ actions: NonNullable<DialogHeaderConfig['actions']>;
301
+ onImage: boolean;
302
+ }): React.ReactElement | null {
303
+ if (actions.length === 0) return null;
304
+ const overflows = actions.length > DIALOG_HEADER_MAX_INLINE_ACTIONS;
305
+ // Keep room for the "more" trigger when collapsing.
306
+ const inline = overflows ? actions.slice(0, DIALOG_HEADER_MAX_INLINE_ACTIONS - 1) : actions;
307
+ const overflow = overflows ? actions.slice(DIALOG_HEADER_MAX_INLINE_ACTIONS - 1) : [];
308
+ return (
309
+ <>
310
+ {inline.map((action) => (
311
+ <FrostedIconButton
312
+ key={action.accessibilityLabel}
313
+ size="sm"
314
+ icon={action.icon}
315
+ onPress={action.onPress}
316
+ disabled={action.disabled}
317
+ accessibilityLabel={action.accessibilityLabel}
318
+ />
319
+ ))}
320
+ {overflow.length > 0 ? <HeaderOverflowMenu items={overflow} onImage={onImage} /> : null}
321
+ </>
322
+ );
323
+ }
324
+
325
+ /**
326
+ * A thin wizard step/progress indicator for the large-title zone. `step` is
327
+ * 1-based; the bar fills `step / total`, clamped to [0, 1].
328
+ */
329
+ function HeaderProgressBar({
330
+ step,
331
+ total,
332
+ onImage,
333
+ }: {
334
+ step: number;
335
+ total: number;
336
+ onImage: boolean;
337
+ }): React.ReactElement {
338
+ const theme = useTheme();
339
+ const fraction = total > 0 ? Math.min(1, Math.max(0, step / total)) : 0;
340
+ return (
341
+ <View
342
+ accessibilityRole="progressbar"
343
+ accessibilityValue={{ min: 0, max: total, now: step }}
344
+ style={[
345
+ styles.progressTrack,
346
+ { backgroundColor: onImage ? 'rgba(255,255,255,0.25)' : theme.colors.border },
347
+ ]}
348
+ >
349
+ <View
350
+ style={[
351
+ styles.progressFill,
352
+ { width: `${Math.round(fraction * 100)}%`, backgroundColor: theme.colors.primary },
353
+ ]}
354
+ />
355
+ </View>
356
+ );
357
+ }
358
+
204
359
  // --- Nav bar overlay --------------------------------------------------------
205
360
 
206
361
  /**
@@ -260,24 +415,62 @@ export const DialogNavHeader = memo(function DialogNavHeader({
260
415
  // reads MUST be in the deps array or it freezes on the first frame.
261
416
  }, [scrollY, largeTitleHeight, hasLargeTitle]);
262
417
 
418
+ // Over media (`tone: 'onImage'`) the scrim strengthens and the default title +
419
+ // icon buttons flip to light content so they stay legible.
420
+ const onImage = config.tone === 'onImage';
421
+ const iconFill = onImage ? ON_IMAGE_TEXT : undefined;
422
+ const titleColor = onImage ? ON_IMAGE_TEXT : theme.colors.text;
423
+ const subtitleColor = onImage ? ON_IMAGE_SUBTEXT : theme.colors.textSecondary;
424
+
425
+ const closeButton = (
426
+ <FrostedIconButton
427
+ onPress={onDismiss}
428
+ accessibilityLabel="Close"
429
+ icon={<TimesLarge_Stroke2_Corner0_Rounded size="md" fill={iconFill} />}
430
+ />
431
+ );
432
+
433
+ // Trailing edge: a custom `right` wins; else the rich trailing (icon actions +
434
+ // the single primary CTA); else the default close affordance.
435
+ const hasRichTrailing = !config.right && !!(config.actions?.length || config.primaryAction);
436
+
437
+ const right = config.right ?? (
438
+ hasRichTrailing ? (
439
+ <View style={styles.trailing}>
440
+ {config.actions?.length ? (
441
+ <HeaderTrailingActions actions={config.actions} onImage={onImage} />
442
+ ) : null}
443
+ {config.primaryAction ? (
444
+ <Button
445
+ variant="primary"
446
+ size="small"
447
+ onPress={config.primaryAction.onPress}
448
+ disabled={config.primaryAction.disabled || config.primaryAction.loading}
449
+ loading={config.primaryAction.loading}
450
+ accessibilityLabel={config.primaryAction.label}
451
+ >
452
+ {config.primaryAction.label}
453
+ </Button>
454
+ ) : null}
455
+ </View>
456
+ ) : config.showClose !== false ? (
457
+ closeButton
458
+ ) : null
459
+ );
460
+
461
+ // Leading edge: a custom `left` wins; else the back button; else — when the
462
+ // trailing edge is taken by the rich CTA and there is no back — the close
463
+ // affordance moves here so there is always exactly one dismiss control.
263
464
  const left =
264
465
  config.left ??
265
466
  (config.onBack ? (
266
467
  <FrostedIconButton
267
468
  onPress={config.onBack}
268
469
  accessibilityLabel="Go back"
269
- icon={<ChevronLeft_Stroke2_Corner0_Rounded size="md" />}
270
- />
271
- ) : null);
272
-
273
- const right =
274
- config.right ??
275
- (config.showClose !== false ? (
276
- <FrostedIconButton
277
- onPress={onDismiss}
278
- accessibilityLabel="Close"
279
- icon={<TimesLarge_Stroke2_Corner0_Rounded size="md" />}
470
+ icon={<ChevronLeft_Stroke2_Corner0_Rounded size="md" fill={iconFill} />}
280
471
  />
472
+ ) : hasRichTrailing && config.showClose !== false ? (
473
+ closeButton
281
474
  ) : null);
282
475
 
283
476
  return (
@@ -286,10 +479,15 @@ export const DialogNavHeader = memo(function DialogNavHeader({
286
479
  style={[styles.overlay, style]}
287
480
  >
288
481
  {/* Opaque (surface bg) at the top → transparent at the bottom, so scrolled
289
- content fades out under the bar. Pure NativeWind no SVG dependency. */}
482
+ content fades out under the bar. `onImage` swaps to a dark scrim so the
483
+ chrome reads over media. Pure NativeWind — no SVG dependency. */}
290
484
  <View
291
485
  pointerEvents="none"
292
- className="bg-gradient-to-b from-bg to-transparent"
486
+ className={
487
+ onImage
488
+ ? 'bg-gradient-to-b from-black/60 to-transparent'
489
+ : 'bg-gradient-to-b from-bg to-transparent'
490
+ }
293
491
  style={[StyleSheet.absoluteFill, { height: DIALOG_HEADER_OVERLAY_HEIGHT }]}
294
492
  />
295
493
  <View pointerEvents="box-none" style={styles.navRow}>
@@ -301,7 +499,7 @@ export const DialogNavHeader = memo(function DialogNavHeader({
301
499
  >
302
500
  {config.titleContent ?? null}
303
501
  {!config.titleContent && config.title ? (
304
- <Text numberOfLines={1} style={[styles.smallTitle, { color: theme.colors.text }]}>
502
+ <Text numberOfLines={1} style={[styles.smallTitle, { color: titleColor }]}>
305
503
  {config.title}
306
504
  </Text>
307
505
  ) : null}
@@ -310,10 +508,7 @@ export const DialogNavHeader = memo(function DialogNavHeader({
310
508
  screen keeps a clean single-line collapsed title, and a branded
311
509
  `titleContent` bar carries no supporting copy at all. */}
312
510
  {!hasLargeTitle && !config.titleContent && config.subtitle ? (
313
- <Text
314
- numberOfLines={1}
315
- style={[styles.smallSubtitle, { color: theme.colors.textSecondary }]}
316
- >
511
+ <Text numberOfLines={1} style={[styles.smallSubtitle, { color: subtitleColor }]}>
317
512
  {config.subtitle}
318
513
  </Text>
319
514
  ) : null}
@@ -346,25 +541,83 @@ export const DialogLargeTitle = memo(function DialogLargeTitle({
346
541
  // A branded `titleContent` lives in the bar and never collapses, so the
347
542
  // surface starts flush under the bar with no large title above it.
348
543
  const hasLargeTitle = !config.titleContent && (config.largeTitle ?? true) && !!config.title;
544
+ // The large-title zone also hosts search / segments / progress. These require a
545
+ // scrolling surface (they live here, not in the fixed bar) and collapse with the
546
+ // title on scroll (iOS `.searchable` style).
547
+ const hasExtras = !!(config.search || config.segments || config.progress);
548
+ const onImage = config.tone === 'onImage';
349
549
 
350
550
  const onLayout = (e: LayoutChangeEvent) => {
351
551
  largeTitleHeight.value = e.nativeEvent.layout.height;
352
552
  };
353
553
 
354
- if (!hasLargeTitle) {
355
- // No large title: just inset the content below the nav bar.
356
- return <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />;
554
+ if (!hasLargeTitle && !hasExtras) {
555
+ // Pure content (no header chrome to render in-flow). Under `tone: 'onImage'`
556
+ // the content is media (a banner / photo canvas), so it slides UP under the
557
+ // translucent nav bar — the bar FLOATS over the media, immersive at rest (the
558
+ // standard iOS large-title-over-photo / Material collapsing-toolbar pattern).
559
+ // Default tone keeps the nav-bar-height inset so content clears the bar.
560
+ return onImage ? null : <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />;
357
561
  }
358
562
 
359
563
  return (
360
564
  <>
565
+ {/* Clear the whole gradient overlay so the block starts below the bar. */}
361
566
  <View style={{ height: DIALOG_HEADER_CONTENT_TOP }} />
362
567
  <View onLayout={onLayout} testID="dialog-large-title" style={styles.largeTitleBlock}>
363
- <H1 style={[styles.largeTitle, { color: theme.colors.text }]}>{config.title}</H1>
364
- {config.subtitle ? (
365
- <Text style={[styles.largeSubtitle, { color: theme.colors.textSecondary }]}>
366
- {config.subtitle}
367
- </Text>
568
+ {hasLargeTitle ? (
569
+ <>
570
+ <H1 style={[styles.largeTitle, { color: onImage ? ON_IMAGE_TEXT : theme.colors.text }]}>
571
+ {config.title}
572
+ </H1>
573
+ {config.subtitle ? (
574
+ <Text
575
+ style={[
576
+ styles.largeSubtitle,
577
+ { color: onImage ? ON_IMAGE_SUBTEXT : theme.colors.textSecondary },
578
+ ]}
579
+ >
580
+ {config.subtitle}
581
+ </Text>
582
+ ) : null}
583
+ </>
584
+ ) : null}
585
+ {config.search ? (
586
+ <View style={hasLargeTitle ? styles.extraRow : undefined}>
587
+ <Search
588
+ value={config.search.value}
589
+ onChangeText={config.search.onChangeText}
590
+ label={config.search.placeholder ?? 'Search'}
591
+ onSubmitEditing={config.search.onSubmit}
592
+ onClearText={() => config.search?.onChangeText('')}
593
+ />
594
+ </View>
595
+ ) : null}
596
+ {config.segments ? (
597
+ <View style={hasLargeTitle || config.search ? styles.extraRow : undefined}>
598
+ <SegmentedControl
599
+ label={config.title ?? 'View'}
600
+ type="tabs"
601
+ size="small"
602
+ value={config.segments.value}
603
+ onChange={config.segments.onChange}
604
+ >
605
+ {config.segments.items.map((item) => (
606
+ <SegmentedControlItem key={item.key} value={item.key}>
607
+ <SegmentedControlItemText>{item.label}</SegmentedControlItemText>
608
+ </SegmentedControlItem>
609
+ ))}
610
+ </SegmentedControl>
611
+ </View>
612
+ ) : null}
613
+ {config.progress ? (
614
+ <View style={hasLargeTitle || config.search || config.segments ? styles.extraRow : undefined}>
615
+ <HeaderProgressBar
616
+ step={config.progress.step}
617
+ total={config.progress.total}
618
+ onImage={onImage}
619
+ />
620
+ </View>
368
621
  ) : null}
369
622
  </View>
370
623
  </>
@@ -394,6 +647,12 @@ const styles = StyleSheet.create({
394
647
  sideRight: {
395
648
  justifyContent: 'flex-end',
396
649
  },
650
+ /** Trailing row: icon actions + the primary CTA, right-aligned. */
651
+ trailing: {
652
+ flexDirection: 'row',
653
+ alignItems: 'center',
654
+ gap: 8,
655
+ },
397
656
  centerTitle: {
398
657
  flex: 1,
399
658
  alignItems: 'center',
@@ -426,4 +685,19 @@ const styles = StyleSheet.create({
426
685
  lineHeight: 22,
427
686
  marginTop: 4,
428
687
  },
688
+ /** Spacing between the large title and each large-title-zone extra. */
689
+ extraRow: {
690
+ marginTop: 12,
691
+ },
692
+ /** Thin wizard progress bar in the large-title zone. */
693
+ progressTrack: {
694
+ height: 4,
695
+ width: '100%',
696
+ borderRadius: 2,
697
+ overflow: 'hidden',
698
+ },
699
+ progressFill: {
700
+ height: '100%',
701
+ borderRadius: 2,
702
+ },
429
703
  });
@@ -53,6 +53,87 @@ export interface DialogHeaderConfig {
53
53
  * affordance entirely (e.g. a blocking surface).
54
54
  */
55
55
  showClose?: boolean;
56
+
57
+ // -------------------------------------------------------------------------
58
+ // Rich navigation-header fields (all OPTIONAL + backward-compatible).
59
+ //
60
+ // Modeled on iOS `UINavigationBar` / SwiftUI `.toolbar` + Material 3
61
+ // `TopAppBar`. Nav-row trailing = [actions…] + [primaryAction]; the
62
+ // collapsing large-title zone stacks title → search → segments → progress.
63
+ // A screen that sets none of these renders byte-for-byte as before. Object
64
+ // fields are compared by identity — memoize them so the header does not
65
+ // thrash (same contract as `left`/`right`).
66
+ // -------------------------------------------------------------------------
67
+
68
+ /**
69
+ * The ONE trailing call-to-action (Upload / Use photo / Save / Done) — a
70
+ * proper Bloom `Button` rendered at the trailing edge of the nav row, AFTER
71
+ * any `actions`. Standardizes what screens used to cram into `right`; supports
72
+ * `disabled` and a `loading` spinner (async submit). When set (and no explicit
73
+ * `right`), the default close affordance moves to the leading edge if the
74
+ * screen has no `onBack`, so there is always exactly one dismiss control.
75
+ */
76
+ primaryAction?: {
77
+ label: string;
78
+ onPress: () => void;
79
+ disabled?: boolean;
80
+ loading?: boolean;
81
+ };
82
+
83
+ /**
84
+ * Trailing icon buttons (share / more / sort), rendered as frosted circles
85
+ * BEFORE `primaryAction`. Past {@link DIALOG_HEADER_MAX_INLINE_ACTIONS} the
86
+ * surplus collapses into a "more" overflow menu (Material pattern) built on
87
+ * Bloom's `Menu` — never hand-rolled. Each item's `accessibilityLabel` is also
88
+ * its overflow-menu row text.
89
+ */
90
+ actions?: Array<{
91
+ icon: ReactNode;
92
+ accessibilityLabel: string;
93
+ onPress: () => void;
94
+ disabled?: boolean;
95
+ }>;
96
+
97
+ /**
98
+ * A search field in the collapsing large-title zone (iOS `.searchable` style:
99
+ * sits under the title and scrolls away with it). Requires a scrolling surface
100
+ * (`scrollable !== false`) — it lives in the same in-content block as the large
101
+ * title. Built on Bloom's `Search`.
102
+ */
103
+ search?: {
104
+ value: string;
105
+ onChangeText: (text: string) => void;
106
+ placeholder?: string;
107
+ onSubmit?: () => void;
108
+ };
109
+
110
+ /**
111
+ * A segmented control row UNDER the title (Material secondary row) for view
112
+ * switching. Requires a scrolling surface (lives in the large-title zone).
113
+ * Built on Bloom's `SegmentedControl` (`type="tabs"`).
114
+ */
115
+ segments?: {
116
+ items: Array<{ key: string; label: string }>;
117
+ value: string;
118
+ onChange: (key: string) => void;
119
+ };
120
+
121
+ /**
122
+ * Over a banner / dark media canvas, `'onImage'` strengthens the gradient
123
+ * scrim and forces light-content title + default icon buttons so the chrome
124
+ * stays legible over the media. `'default'` (the default) is unchanged.
125
+ */
126
+ tone?: 'default' | 'onImage';
127
+
128
+ /**
129
+ * A thin step/progress indicator for wizards, rendered in the large-title zone
130
+ * below any title/search/segments. `step` is 1-based; the bar fills
131
+ * `step / total`.
132
+ */
133
+ progress?: {
134
+ step: number;
135
+ total: number;
136
+ };
56
137
  }
57
138
 
58
139
  /**