@oxy.so/bloom 4.20.0 → 4.21.1

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 (47) hide show
  1. package/docs/adoption-matrix.mdx +3 -3
  2. package/docs/button.mdx +1 -1
  3. package/docs/design-tokens.mdx +22 -0
  4. package/docs/dialog.mdx +7 -0
  5. package/lib/commonjs/accordion/Accordion.js +7 -3
  6. package/lib/commonjs/accordion/Accordion.js.map +1 -1
  7. package/lib/commonjs/button/Button.js +37 -1
  8. package/lib/commonjs/button/Button.js.map +1 -1
  9. package/lib/commonjs/design-tokens/theme-css.js +24 -2
  10. package/lib/commonjs/design-tokens/theme-css.js.map +1 -1
  11. package/lib/commonjs/design-tokens/theme.css +8 -8
  12. package/lib/commonjs/dialog/DialogBottomSheet.js +19 -3
  13. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  14. package/lib/commonjs/dialog/DialogHeader.js +146 -6
  15. package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
  16. package/lib/module/accordion/Accordion.js +7 -3
  17. package/lib/module/accordion/Accordion.js.map +1 -1
  18. package/lib/module/button/Button.js +38 -2
  19. package/lib/module/button/Button.js.map +1 -1
  20. package/lib/module/design-tokens/theme-css.js +23 -2
  21. package/lib/module/design-tokens/theme-css.js.map +1 -1
  22. package/lib/module/design-tokens/theme.css +8 -8
  23. package/lib/module/dialog/DialogBottomSheet.js +19 -3
  24. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  25. package/lib/module/dialog/DialogHeader.js +144 -5
  26. package/lib/module/dialog/DialogHeader.js.map +1 -1
  27. package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts +9 -1
  30. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +24 -0
  33. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -1
  34. package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
  35. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  36. package/lib/typescript/module/design-tokens/theme-css.d.ts +9 -1
  37. package/lib/typescript/module/design-tokens/theme-css.d.ts.map +1 -1
  38. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  39. package/lib/typescript/module/dialog/DialogHeader.d.ts +24 -0
  40. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -1
  41. package/package.json +5 -2
  42. package/src/accordion/Accordion.tsx +9 -1
  43. package/src/button/Button.tsx +41 -3
  44. package/src/design-tokens/theme-css.ts +23 -2
  45. package/src/design-tokens/theme.css +8 -8
  46. package/src/dialog/DialogBottomSheet.tsx +21 -0
  47. package/src/dialog/DialogHeader.tsx +143 -14
@@ -22,6 +22,14 @@
22
22
  * A consumer that prefers a static stylesheet can copy the output of
23
23
  * `bloomThemeCss()` directly into their `@theme { … }` — the values are stable.
24
24
  */
25
+ /**
26
+ * A px line-height as the unitless ratio of its font-size, spelled the way
27
+ * Tailwind spells its own type scale: `lineHeightRatio(22, 15)` → `calc(22 / 15)`.
28
+ * Exact on both platforms (no rounded decimal), and the only form
29
+ * react-native-css resolves correctly through `var()` — it multiplies any
30
+ * number by the font-size.
31
+ */
32
+ export declare function lineHeightRatio(lineHeightPx: number, fontSizePx: number): string;
25
33
  /**
26
34
  * Produce the BODY of a Tailwind v4 `@theme` block (no surrounding braces) that
27
35
  * registers the full Bloom semantic vocabulary.
@@ -29,7 +37,7 @@
29
37
  * - Colors → `--color-<role>: var(--canonical);`
30
38
  * - Spacing → `--spacing-<key>: <px>;`
31
39
  * - Radius → `--radius-<key>: <px>;`
32
- * - Type → `--text-<role>: <size>; --text-<role>--line-height: <lh>;`
40
+ * - Type → `--text-<role>: <size>; --text-<role>--line-height: calc(<lh> / <size>);`
33
41
  * `--font-<role>: var(--bloom-font-*);` (+ weight via utility doc)
34
42
  * - Shadow → `--shadow-<s|m>: <box-shadow>;`
35
43
  */
@@ -1 +1 @@
1
- {"version":3,"file":"theme-css.d.ts","sourceRoot":"","sources":["../../../../src/design-tokens/theme-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAcH;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAkEtC;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,IAAI,MAAM,CAExC"}
1
+ {"version":3,"file":"theme-css.d.ts","sourceRoot":"","sources":["../../../../src/design-tokens/theme-css.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAcH;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEhF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,IAAI,MAAM,CA4EtC;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,IAAI,MAAM,CAExC"}
@@ -1 +1 @@
1
- {"version":3,"file":"DialogBottomSheet.d.ts","sourceRoot":"","sources":["../../../../src/dialog/DialogBottomSheet.tsx"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,IAAI,EAAE,cAAc,EACpB,SAAS,EACT,OAAO,EACP,MAAM,EACN,KAAK,EACL,WAAW,EACX,OAAO,EACP,MAAM,EACN,cAAyC,EACzC,UAAiB,EACjB,iBAAwB,EACxB,cAA+C,EAC/C,UAAU,EACV,KAAK,EACL,KAAK,EACL,UAAU,EACV,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,KAAK,EACL,QAAQ,GACT,EAAE,sBAAsB,2CA4PxB;AAKD;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG,IAAI,CACvC,WAAW,EACT,SAAS,GACT,MAAM,GACN,WAAW,GACX,SAAS,GACT,QAAQ,GACR,OAAO,GACP,aAAa,GACb,SAAS,GACT,QAAQ,GACR,gBAAgB,GAChB,YAAY,GACZ,mBAAmB,GACnB,gBAAgB,GAChB,YAAY,GACZ,OAAO,GACP,OAAO,GACP,YAAY,GACZ,gBAAgB,GAChB,gBAAgB,GAChB,oBAAoB,GACpB,OAAO,GACP,UAAU,CACb,CAAC"}
1
+ {"version":3,"file":"DialogBottomSheet.d.ts","sourceRoot":"","sources":["../../../../src/dialog/DialogBottomSheet.tsx"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,IAAI,EAAE,cAAc,EACpB,SAAS,EACT,OAAO,EACP,MAAM,EACN,KAAK,EACL,WAAW,EACX,OAAO,EACP,MAAM,EACN,cAAyC,EACzC,UAAiB,EACjB,iBAAwB,EACxB,cAA+C,EAC/C,UAAU,EACV,KAAK,EACL,KAAK,EACL,UAAU,EACV,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,KAAK,EACL,QAAQ,GACT,EAAE,sBAAsB,2CA6QxB;AAQD;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG,IAAI,CACvC,WAAW,EACT,SAAS,GACT,MAAM,GACN,WAAW,GACX,SAAS,GACT,QAAQ,GACR,OAAO,GACP,aAAa,GACb,SAAS,GACT,QAAQ,GACR,gBAAgB,GAChB,YAAY,GACZ,mBAAmB,GACnB,gBAAgB,GAChB,YAAY,GACZ,OAAO,GACP,OAAO,GACP,YAAY,GACZ,gBAAgB,GAChB,gBAAgB,GAChB,oBAAoB,GACpB,OAAO,GACP,UAAU,CACb,CAAC"}
@@ -12,6 +12,30 @@ export declare const DIALOG_HEADER_OVERLAY_HEIGHT: number;
12
12
  * BELOW the whole gradient overlay (fully clear at rest, then scrolls under it).
13
13
  */
14
14
  export declare const DIALOG_HEADER_CONTENT_TOP: number;
15
+ /**
16
+ * Content top inset (px) for an INLINE-CLOSE header (see {@link isInlineCloseHeader}):
17
+ * the large title's first line is centred on the nav row, level with the ✕,
18
+ * instead of starting below the whole bar.
19
+ */
20
+ export declare const DIALOG_HEADER_INLINE_CONTENT_TOP: number;
21
+ /**
22
+ * Whether a large-title header puts its title IN the nav row, beside the ✕,
23
+ * rather than in a band below the bar.
24
+ *
25
+ * The nav row exists for its controls. When it would hold nothing but the
26
+ * default close — no back, no leading slot, no trailing actions, no custom
27
+ * trailing node — a full 72px row above the title is an empty band on every
28
+ * entry view of a flow (an account dialog's first screen, a one-page sheet).
29
+ * The title then starts level with the ✕ and wraps before it; the bar's scrim
30
+ * and the small collapsed title still arrive once the title scrolls under it.
31
+ *
32
+ * Anything that gives the row content of its own keeps the stacked layout: a
33
+ * back button (the title would sit between two buttons), rich trailing actions
34
+ * or a custom `right` (unknown width), a branded `titleContent` (no large title
35
+ * at all), and `tone: 'onImage'` (the scrim is what makes the chrome legible
36
+ * over media, so it cannot start hidden).
37
+ */
38
+ export declare function isInlineCloseHeader(config: DialogHeaderConfig): boolean;
15
39
  /**
16
40
  * Trailing icon `actions` shown inline before they collapse into a "more"
17
41
  * overflow menu (Material overflow pattern). Kept small so the nav row never
@@ -1 +1 @@
1
- {"version":3,"file":"DialogHeader.d.ts","sourceRoot":"","sources":["../../../../src/dialog/DialogHeader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAiB,EAKf,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAC;AAuBjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,YAAY,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAiBlD,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAKxC,uDAAuD;AACvD,eAAO,MAAM,4BAA4B,QAAwC,CAAC;AAElF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAA+B,CAAC;AAQtE;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAgBlD,UAAU,WAAW;IACnB,WAAW,EAAE,MAAM,kBAAkB,GAAG,IAAI,CAAC;IAC7C,WAAW,EAAE,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;CACjD;AAsGD;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC7B,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,IAAI,sBAAsB,CAQlE;AAID;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,UAAU,EACV,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,GAAG,KAAK,CAAC,YAAY,CAMrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CA2BnF;AAiJD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe;gBAOd,sBAAsB;YAC1B,kBAAkB;eACf,MAAM,IAAI;IACrB;;;;;OAKG;eACQ,OAAO;YACV,SAAS,CAAC,SAAS,CAAC;EAuH5B,CAAC;AAIH;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;gBAIf,sBAAsB;YAC1B,kBAAkB;EA4F1B,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB;gBAIjB,sBAAsB;YAC1B,kBAAkB;EAK1B,CAAC"}
1
+ {"version":3,"file":"DialogHeader.d.ts","sourceRoot":"","sources":["../../../../src/dialog/DialogHeader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAiB,EAKf,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAC;AAyBjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,YAAY,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAiBlD,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAKxC,uDAAuD;AACvD,eAAO,MAAM,4BAA4B,QAAwC,CAAC;AAElF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAA+B,CAAC;AAWtE;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,QAAwD,CAAC;AAYtG;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAOvE;AAED;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAgBlD,UAAU,WAAW;IACnB,WAAW,EAAE,MAAM,kBAAkB,GAAG,IAAI,CAAC;IAC7C,WAAW,EAAE,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;CACjD;AAsGD;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC7B,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,IAAI,sBAAsB,CAQlE;AAID;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,UAAU,EACV,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,GAAG,KAAK,CAAC,YAAY,CAMrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CA2BnF;AAmMD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe;gBAOd,sBAAsB;YAC1B,kBAAkB;eACf,MAAM,IAAI;IACrB;;;;;OAKG;eACQ,OAAO;YACV,SAAS,CAAC,SAAS,CAAC;EAqI5B,CAAC;AAIH;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;gBAIf,sBAAsB;YAC1B,kBAAkB;EA6G1B,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB;gBAIjB,sBAAsB;YAC1B,kBAAkB;EAK1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/bloom",
3
- "version": "4.20.0",
3
+ "version": "4.21.1",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -3422,7 +3422,10 @@
3422
3422
  "basic-ftp": "5.2.2",
3423
3423
  "defu": "6.1.7",
3424
3424
  "handlebars": "4.7.9",
3425
- "lodash": "4.18.1"
3425
+ "lodash": "4.18.1",
3426
+ "lightningcss": "1.30.1",
3427
+ "lightningcss-linux-x64-gnu": "1.30.1",
3428
+ "lightningcss-linux-x64-musl": "1.30.1"
3426
3429
  },
3427
3430
  "typesVersions": {
3428
3431
  "*": {
@@ -140,6 +140,9 @@ const AccordionItemComponent: React.FC<AccordionItemProps> = ({
140
140
 
141
141
  // ---- Accordion Trigger ----
142
142
 
143
+ /** The trigger's label box: fills the row, sized from its content (`flexBasis: 'auto'`). */
144
+ const TRIGGER_LABEL_STYLE = { flexGrow: 1, flexShrink: 1 } as const;
145
+
143
146
  const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
144
147
  children,
145
148
  icon,
@@ -200,7 +203,12 @@ const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
200
203
  aria-expanded={isExpanded}
201
204
  >
202
205
  {icon}
203
- <View style={{ flex: 1 }}>
206
+ {/* Grow into a full-width trigger, but measure at the label's natural
207
+ width. RN's `flex: 1` is `flexBasis: 0`, and in a shrink-wrapped
208
+ trigger (`alignSelf: 'center'`) Yoga then sized the label at its
209
+ MINIMUM content width: "Having trouble?" wrapped to two lines on
210
+ Android while web (max-content sizing) kept it on one. */}
211
+ <View testID="accordion-trigger-label" style={TRIGGER_LABEL_STYLE}>
204
212
  {typeof children === 'string' ? (
205
213
  <Text
206
214
  style={[
@@ -4,6 +4,7 @@ import { resolveIconSlot } from '../icons/render-icon';
4
4
  import {
5
5
  ActivityIndicator,
6
6
  Linking,
7
+ Platform,
7
8
  Pressable,
8
9
  StyleSheet,
9
10
  View,
@@ -80,6 +81,42 @@ const SQUARE_HIT_SLOP = {
80
81
  lg: { top: 0, bottom: 0, left: 0, right: 0 },
81
82
  } as const satisfies Record<ButtonResolvedSize, NonNullable<ButtonProps['hitSlop']>>;
82
83
 
84
+ type HitSlop = { top: number; bottom: number; left: number; right: number };
85
+
86
+ /**
87
+ * Android's floor is 48dp (Material), 4dp above the 44 the tables above reach —
88
+ * so on Android every target grows 2dp at its top and bottom: `lg` (44dp tall,
89
+ * no slack) reaches 48, and every smaller size clears 48 the same way. Squares
90
+ * also grow 2dp at the sides, since they already take all-round slack; row
91
+ * buttons and links do not, because side slack steals presses from a
92
+ * neighbour. Two stacked buttons keep 4dp of every 8dp gap between them.
93
+ */
94
+ const ANDROID_TARGET_EXTRA = 2;
95
+
96
+ function growVertical(slop: HitSlop): HitSlop {
97
+ return { ...slop, top: slop.top + ANDROID_TARGET_EXTRA, bottom: slop.bottom + ANDROID_TARGET_EXTRA };
98
+ }
99
+
100
+ function growAllRound(slop: HitSlop): HitSlop {
101
+ return {
102
+ top: slop.top + ANDROID_TARGET_EXTRA,
103
+ bottom: slop.bottom + ANDROID_TARGET_EXTRA,
104
+ left: slop.left + ANDROID_TARGET_EXTRA,
105
+ right: slop.right + ANDROID_TARGET_EXTRA,
106
+ };
107
+ }
108
+
109
+ function mapSizes(
110
+ table: Record<ButtonResolvedSize, HitSlop>,
111
+ grow: (slop: HitSlop) => HitSlop,
112
+ ): Record<ButtonResolvedSize, HitSlop> {
113
+ return { xs: grow(table.xs), sm: grow(table.sm), md: grow(table.md), lg: grow(table.lg) };
114
+ }
115
+
116
+ const ANDROID_SIZE_HIT_SLOP = mapSizes(SIZE_HIT_SLOP, growVertical);
117
+ const ANDROID_SQUARE_HIT_SLOP = mapSizes(SQUARE_HIT_SLOP, growAllRound);
118
+ const ANDROID_LINK_HIT_SLOP = growVertical(LINK_HIT_SLOP);
119
+
83
120
  // ---------------------------------------------------------------------------
84
121
  // The button renders ONE node — the same shape the web fork renders (one
85
122
  // `<button>`), so a caller's layout `className` lands on the box its parent
@@ -286,11 +323,12 @@ const ButtonComponent: React.FC<ButtonProps> = ({
286
323
  [geometry, paint.foreground, isLink, underlineMode, hovered, disabled],
287
324
  );
288
325
 
326
+ const android = Platform.OS === 'android';
289
327
  const defaultHitSlop = isSquare
290
- ? SQUARE_HIT_SLOP[size]
328
+ ? (android ? ANDROID_SQUARE_HIT_SLOP : SQUARE_HIT_SLOP)[size]
291
329
  : isLink
292
- ? LINK_HIT_SLOP
293
- : SIZE_HIT_SLOP[size];
330
+ ? (android ? ANDROID_LINK_HIT_SLOP : LINK_HIT_SLOP)
331
+ : (android ? ANDROID_SIZE_HIT_SLOP : SIZE_HIT_SLOP)[size];
294
332
 
295
333
  const IconFromProp = icon != null && isIconComponent(icon) ? icon : null;
296
334
  const iconNode = IconFromProp ? (
@@ -35,6 +35,17 @@ import {
35
35
  } from './scales';
36
36
  import { SHADOW_BOX } from './shadows';
37
37
 
38
+ /**
39
+ * A px line-height as the unitless ratio of its font-size, spelled the way
40
+ * Tailwind spells its own type scale: `lineHeightRatio(22, 15)` → `calc(22 / 15)`.
41
+ * Exact on both platforms (no rounded decimal), and the only form
42
+ * react-native-css resolves correctly through `var()` — it multiplies any
43
+ * number by the font-size.
44
+ */
45
+ export function lineHeightRatio(lineHeightPx: number, fontSizePx: number): string {
46
+ return `calc(${lineHeightPx} / ${fontSizePx})`;
47
+ }
48
+
38
49
  /**
39
50
  * Produce the BODY of a Tailwind v4 `@theme` block (no surrounding braces) that
40
51
  * registers the full Bloom semantic vocabulary.
@@ -42,7 +53,7 @@ import { SHADOW_BOX } from './shadows';
42
53
  * - Colors → `--color-<role>: var(--canonical);`
43
54
  * - Spacing → `--spacing-<key>: <px>;`
44
55
  * - Radius → `--radius-<key>: <px>;`
45
- * - Type → `--text-<role>: <size>; --text-<role>--line-height: <lh>;`
56
+ * - Type → `--text-<role>: <size>; --text-<role>--line-height: calc(<lh> / <size>);`
46
57
  * `--font-<role>: var(--bloom-font-*);` (+ weight via utility doc)
47
58
  * - Shadow → `--shadow-<s|m>: <box-shadow>;`
48
59
  */
@@ -94,10 +105,20 @@ export function bloomThemeCss(): string {
94
105
  }
95
106
 
96
107
  // Typography: size + line-height + family per role.
108
+ //
109
+ // The line-height is a UNITLESS ratio (`calc(22 / 15)`), the form Tailwind's
110
+ // own `--text-sm--line-height: calc(1.25 / 0.875)` takes — never px. On the
111
+ // web the two are identical (a unitless line-height is a multiple of the
112
+ // font-size: 15px × 22/15 = 22px). On native they are not: Tailwind v4 emits
113
+ // `line-height: var(--tw-leading, var(--text-body--line-height))`,
114
+ // react-native-css compiles that to `lineHeight(var(…, 22))`, and its runtime
115
+ // `lineHeight` resolver treats EVERY number as an em multiplier — so a px
116
+ // token rendered `text-body` at 22 × 15 = 330dp line-height on Android and
117
+ // iOS. See `lineHeightRatio` and `docs/typography.mdx`.
97
118
  for (const name of Object.keys(TYPOGRAPHY) as TypeRoleName[]) {
98
119
  const role = TYPOGRAPHY[name];
99
120
  lines.push(` --text-${name}: ${role.size}px;`);
100
- lines.push(` --text-${name}--line-height: ${role.lineHeight}px;`);
121
+ lines.push(` --text-${name}--line-height: ${lineHeightRatio(role.lineHeight, role.size)};`);
101
122
  lines.push(` --text-${name}--font-weight: ${role.weight};`);
102
123
  lines.push(` --font-${name}: ${FONT_FAMILY_VARS[role.family]};`);
103
124
  }
@@ -124,35 +124,35 @@
124
124
  --radius-radius-max: 9999px;
125
125
  --border-width-hairline: 0.5px;
126
126
  --text-caption: 11px;
127
- --text-caption--line-height: 14px;
127
+ --text-caption--line-height: calc(14 / 11);
128
128
  --text-caption--font-weight: 400;
129
129
  --font-caption: var(--bloom-font-sans);
130
130
  --text-bodySmall: 13px;
131
- --text-bodySmall--line-height: 18px;
131
+ --text-bodySmall--line-height: calc(18 / 13);
132
132
  --text-bodySmall--font-weight: 400;
133
133
  --font-bodySmall: var(--bloom-font-sans);
134
134
  --text-bodyTitleSmall: 13px;
135
- --text-bodyTitleSmall--line-height: 18px;
135
+ --text-bodyTitleSmall--line-height: calc(18 / 13);
136
136
  --text-bodyTitleSmall--font-weight: 600;
137
137
  --font-bodyTitleSmall: var(--bloom-font-sans);
138
138
  --text-body: 15px;
139
- --text-body--line-height: 22px;
139
+ --text-body--line-height: calc(22 / 15);
140
140
  --text-body--font-weight: 400;
141
141
  --font-body: var(--bloom-font-sans);
142
142
  --text-subtitle: 17px;
143
- --text-subtitle--line-height: 24px;
143
+ --text-subtitle--line-height: calc(24 / 17);
144
144
  --text-subtitle--font-weight: 500;
145
145
  --font-subtitle: var(--bloom-font-sans);
146
146
  --text-sectionTitle: 20px;
147
- --text-sectionTitle--line-height: 26px;
147
+ --text-sectionTitle--line-height: calc(26 / 20);
148
148
  --text-sectionTitle--font-weight: 600;
149
149
  --font-sectionTitle: var(--bloom-font-sans);
150
150
  --text-headerBold: 28px;
151
- --text-headerBold--line-height: 34px;
151
+ --text-headerBold--line-height: calc(34 / 28);
152
152
  --text-headerBold--font-weight: 700;
153
153
  --font-headerBold: var(--bloom-font-display);
154
154
  --text-buttonLarge: 17px;
155
- --text-buttonLarge--line-height: 22px;
155
+ --text-buttonLarge--line-height: calc(22 / 17);
156
156
  --text-buttonLarge--font-weight: 600;
157
157
  --font-buttonLarge: var(--bloom-font-sans);
158
158
  --font-bloom-sans: var(--bloom-font-sans);
@@ -13,6 +13,7 @@ import {
13
13
  type ViewStyle,
14
14
  } from 'react-native';
15
15
  import { type AnimatedStyle } from 'react-native-reanimated';
16
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
16
17
 
17
18
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
18
19
  import { useTheme } from '../theme/use-theme';
@@ -86,6 +87,11 @@ export function DialogBottomSheet({
86
87
  const titleId = useId();
87
88
  const descriptionId = useId();
88
89
  const headerController = useDialogHeaderController();
90
+ // The sheet is flush with the bottom of the window (a `<Modal>` drawn
91
+ // edge-to-edge on Android, and under the home indicator on iOS), and
92
+ // `BottomSheet` deliberately leaves the safe area to its content. This Dialog
93
+ // IS the content's chrome, so it owns the inset (the spacer after the body).
94
+ const { bottom: bottomInset } = useSafeAreaInsets();
89
95
 
90
96
  // Read the latest controlled flag / `onClose` inside stable callbacks without
91
97
  // re-binding them (the context + imperative handle depend on `close` staying
@@ -325,12 +331,27 @@ export function DialogBottomSheet({
325
331
  <DialogMorphContent morph={morphState} style={scrollableResolved ? undefined : FILL_BOUNDED}>
326
332
  {header ? headerBody : dialogBody}
327
333
  </DialogMorphContent>
334
+ {/* The bottom safe area, INSIDE the scroll content. Without it the
335
+ last row of every bottom-placement Dialog was painted under the
336
+ Android gesture bar / iOS home indicator (a 2400px Pixel put a
337
+ "Having trouble?" link at 2297–2356px), and a body that outgrew
338
+ the sheet could not scroll its last rows out from under the bar —
339
+ the scroll range ended exactly where the bar began. A spacer
340
+ rather than a padding: the content box keeps the caller's
341
+ `padding`/`contentPadding` untouched on both platforms, and the
342
+ morph layer above still measures the content alone. */}
343
+ {bottomInset > 0 ? (
344
+ <View testID={SAFE_AREA_SPACER_TESTID} style={{ height: bottomInset }} />
345
+ ) : null}
328
346
  </StyledView>
329
347
  </Context.Provider>
330
348
  </BottomSheet>
331
349
  );
332
350
  }
333
351
 
352
+ /** Test handle for the bottom safe-area spacer. */
353
+ const SAFE_AREA_SPACER_TESTID = 'dialog-sheet-safe-area';
354
+
334
355
  /** Passes a bounded height down to a `scrollable={false}` body. */
335
356
  const FILL_BOUNDED = { flex: 1, minHeight: 0 } as const;
336
357
 
@@ -27,6 +27,8 @@ import Animated, {
27
27
  // it put all 461 glyphs into every app that renders a Dialog, which is the cost
28
28
  // `./icons/Ri*` was added to remove. An app cannot opt out of what Bloom's own
29
29
  // components import, so the barrel has no place inside a shipped one.
30
+ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
31
+
30
32
  import { RiArrowLeftSLine } from '../icons/remix/RiArrowLeftSLine';
31
33
  import { RiMoreFill } from '../icons/remix/RiMoreFill';
32
34
  import { RiCloseLine } from '../icons/remix/RiCloseLine';
@@ -83,6 +85,52 @@ const HEADER_H_PADDING = 20;
83
85
  /** Circular icon-button diameter for the default back / close affordances. */
84
86
  const NAV_BUTTON_SIZE = 36;
85
87
 
88
+ /** The large title's line box (px) — `styles.largeTitle.lineHeight`. */
89
+ const LARGE_TITLE_LINE_HEIGHT = 38;
90
+
91
+ /**
92
+ * Content top inset (px) for an INLINE-CLOSE header (see {@link isInlineCloseHeader}):
93
+ * the large title's first line is centred on the nav row, level with the ✕,
94
+ * instead of starting below the whole bar.
95
+ */
96
+ export const DIALOG_HEADER_INLINE_CONTENT_TOP = (DIALOG_NAV_BAR_HEIGHT - LARGE_TITLE_LINE_HEIGHT) / 2;
97
+
98
+ /**
99
+ * Space (px) the large title's first line keeps clear at its trailing edge for
100
+ * the ✕ it shares a row with: the nav row's gutter + the button + an 8px gap,
101
+ * less the title block's own gutter.
102
+ */
103
+ const INLINE_CLOSE_TITLE_RESERVE = HEADER_H_PADDING - 4 + NAV_BUTTON_SIZE + 8 - HEADER_H_PADDING;
104
+
105
+ /** Scroll distance (px) over which the bar's scrim fades in on an inline-close header. */
106
+ const INLINE_SCRIM_FADE_DISTANCE = 16;
107
+
108
+ /**
109
+ * Whether a large-title header puts its title IN the nav row, beside the ✕,
110
+ * rather than in a band below the bar.
111
+ *
112
+ * The nav row exists for its controls. When it would hold nothing but the
113
+ * default close — no back, no leading slot, no trailing actions, no custom
114
+ * trailing node — a full 72px row above the title is an empty band on every
115
+ * entry view of a flow (an account dialog's first screen, a one-page sheet).
116
+ * The title then starts level with the ✕ and wraps before it; the bar's scrim
117
+ * and the small collapsed title still arrive once the title scrolls under it.
118
+ *
119
+ * Anything that gives the row content of its own keeps the stacked layout: a
120
+ * back button (the title would sit between two buttons), rich trailing actions
121
+ * or a custom `right` (unknown width), a branded `titleContent` (no large title
122
+ * at all), and `tone: 'onImage'` (the scrim is what makes the chrome legible
123
+ * over media, so it cannot start hidden).
124
+ */
125
+ export function isInlineCloseHeader(config: DialogHeaderConfig): boolean {
126
+ const hasLargeTitle = !config.titleContent && (config.largeTitle ?? true) && !!config.title;
127
+ if (!hasLargeTitle) return false;
128
+ if (config.tone === 'onImage') return false;
129
+ if (config.left || config.onBack || config.right) return false;
130
+ if (config.actions?.length || config.primaryAction) return false;
131
+ return true;
132
+ }
133
+
86
134
  /**
87
135
  * Trailing icon `actions` shown inline before they collapse into a "more"
88
136
  * overflow menu (Material overflow pattern). Kept small so the nav row never
@@ -435,6 +483,56 @@ function HeaderProgressBar({
435
483
 
436
484
  // --- Nav bar overlay --------------------------------------------------------
437
485
 
486
+ /** Where the default scrim stops being opaque: the bottom of the nav row. */
487
+ const SCRIM_SOLID_UNTIL = DIALOG_NAV_BAR_HEIGHT / DIALOG_HEADER_OVERLAY_HEIGHT;
488
+
489
+ /** Dark scrim over media (`tone: 'onImage'`): 60% black fading to clear. */
490
+ const ON_IMAGE_SCRIM = '#000000';
491
+ const ON_IMAGE_SCRIM_OPACITY = 0.6;
492
+
493
+ let navScrimIdCounter = 0;
494
+
495
+ /**
496
+ * The nav bar's background. It used to be a Tailwind class,
497
+ * `bg-gradient-to-b from-bg to-transparent`. On native that compiles through
498
+ * react-native-css to `experimental_backgroundImage: "linear-gradient(to bottom
499
+ * in oklab, …)"`. React Native cannot parse the `in oklab` interpolation that
500
+ * Tailwind v4 always emits, so it painted NOTHING. The bar was transparent, and
501
+ * once a list scrolled, the collapsed title sat on top of the rows under it.
502
+ * The class also needed the consumer's Tailwind to scan Bloom's `lib/`, or the
503
+ * web bar was bare too. An SVG gradient paints the same on both platforms and
504
+ * has no such dependency.
505
+ *
506
+ * The default scrim is SOLID across the whole nav row and fades only over the
507
+ * tail below it. A gradient spread over the full overlay left the title's own
508
+ * row about two-thirds opaque, so rows passing behind it still showed through.
509
+ * The fade uses `stopOpacity`, never alpha in `stopColor`: SVG drops an
510
+ * embedded alpha (see `glass-colors.ts`).
511
+ */
512
+ function NavScrim({ onImage, surface }: { onImage: boolean; surface: string }): React.ReactElement {
513
+ const id = useMemo(() => `bloom-dialog-nav-scrim${navScrimIdCounter++}`, []);
514
+ const color = onImage ? ON_IMAGE_SCRIM : surface;
515
+ // [offset, opacity] pairs: the dark scrim fades across its whole height; the
516
+ // surface scrim holds solid to the bottom of the nav row first.
517
+ const stops: Array<[number, number]> = onImage
518
+ ? [[0, ON_IMAGE_SCRIM_OPACITY], [1, 0]]
519
+ : [[0, 1], [SCRIM_SOLID_UNTIL, 1], [1, 0]];
520
+ return (
521
+ // `width`/`height` beside `absoluteFill`: an `<svg>` is a replaced element
522
+ // and falls back to 300 x 150 on web without them (`svg-absolute-fill-size`).
523
+ <Svg width="100%" height="100%" style={StyleSheet.absoluteFill} testID="dialog-nav-scrim-fill">
524
+ <Defs>
525
+ <LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
526
+ {stops.map(([offset, opacity]) => (
527
+ <Stop key={offset} offset={String(offset)} stopColor={color} stopOpacity={opacity} />
528
+ ))}
529
+ </LinearGradient>
530
+ </Defs>
531
+ <Rect x="0" y="0" width="100%" height="100%" fill={`url(#${id})`} />
532
+ </Svg>
533
+ );
534
+ }
535
+
438
536
  /**
439
537
  * The sticky nav bar: gradient background + left / center (collapsing title) /
440
538
  * right slots. Absolutely positioned at the top of the Dialog surface, painted
@@ -473,6 +571,17 @@ export const DialogNavHeader = memo(function DialogNavHeader({
473
571
  // so there is nothing to collapse and no large in-content title to collapse under.
474
572
  const hasLargeTitle =
475
573
  collapse && !config.titleContent && (config.largeTitle ?? true) && !!config.title;
574
+ // The large title shares the nav row with the ✕ at rest (`isInlineCloseHeader`),
575
+ // so the scrim — opaque at the top — must not paint over it until content
576
+ // actually scrolls under the bar. Only a collapsing bar has an in-content title.
577
+ const inlineClose = collapse && isInlineCloseHeader(config);
578
+
579
+ const scrimStyle = useAnimatedStyle(() => {
580
+ if (!inlineClose) return { opacity: 1 };
581
+ return {
582
+ opacity: interpolate(scrollY.value, [0, INLINE_SCRIM_FADE_DISTANCE], [0, 1], Extrapolation.CLAMP),
583
+ };
584
+ }, [scrollY, inlineClose]);
476
585
 
477
586
  const titleStyle = useAnimatedStyle(() => {
478
587
  // When there is no large title to collapse under, the bar title is always
@@ -545,17 +654,20 @@ export const DialogNavHeader = memo(function DialogNavHeader({
545
654
 
546
655
  return (
547
656
  <View pointerEvents="box-none" style={[styles.overlay, style]}>
548
- {/* Opaque (surface bg) at the top → transparent at the bottom, so scrolled
549
- content fades out under the bar. `onImage` swaps to a dark scrim so the
550
- chrome reads over media. Pure NativeWind — no SVG dependency. */}
551
- <View
552
- className={
553
- onImage
554
- ? 'bg-gradient-to-b from-black/60 to-transparent'
555
- : 'bg-gradient-to-b from-bg to-transparent'
556
- }
557
- style={[StyleSheet.absoluteFill, { height: DIALOG_HEADER_OVERLAY_HEIGHT, pointerEvents: 'none' }]}
558
- />
657
+ {/* The surface colour behind the nav row, fading out over the tail below
658
+ it, so scrolled content passes UNDER the bar instead of through its
659
+ title. `onImage` swaps to a dark scrim so the chrome reads over media.
660
+ See `NavScrim` for why this is an SVG and not a gradient class. */}
661
+ <Animated.View
662
+ testID="dialog-nav-scrim"
663
+ style={[
664
+ StyleSheet.absoluteFill,
665
+ { height: DIALOG_HEADER_OVERLAY_HEIGHT, pointerEvents: 'none' },
666
+ scrimStyle,
667
+ ]}
668
+ >
669
+ <NavScrim onImage={onImage} surface={theme.colors.background} />
670
+ </Animated.View>
559
671
  <View pointerEvents="box-none" style={styles.navRow}>
560
672
  <View style={styles.side}>{left}</View>
561
673
  <Animated.View
@@ -625,14 +737,31 @@ export const DialogLargeTitle = memo(function DialogLargeTitle({
625
737
  return onImage ? null : <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />;
626
738
  }
627
739
 
740
+ // Title level with the ✕ when the nav row would hold nothing else.
741
+ const inlineClose = isInlineCloseHeader(config);
742
+
628
743
  return (
629
744
  <>
630
- {/* Clear the whole gradient overlay so the block starts below the bar. */}
631
- <View style={{ height: DIALOG_HEADER_CONTENT_TOP }} />
745
+ {/* Clear the whole gradient overlay so the block starts below the bar —
746
+ or, inline with the ✕, just centre the first line on the nav row. */}
747
+ <View
748
+ testID="dialog-large-title-inset"
749
+ style={{ height: inlineClose ? DIALOG_HEADER_INLINE_CONTENT_TOP : DIALOG_HEADER_CONTENT_TOP }}
750
+ />
632
751
  <View onLayout={onLayout} testID="dialog-large-title" style={styles.largeTitleBlock}>
633
752
  {hasLargeTitle ? (
634
753
  <>
635
- <H1 style={[styles.largeTitle, { color: onImage ? ON_IMAGE_TEXT : theme.colors.text }]}>
754
+ <H1
755
+ style={[
756
+ styles.largeTitle,
757
+ { color: onImage ? ON_IMAGE_TEXT : theme.colors.text },
758
+ // Wrap before the ✕ it shares the row with. A logical inset, so
759
+ // it follows the ✕ to the left edge in a right-to-left layout.
760
+ inlineClose && config.showClose !== false
761
+ ? { marginInlineEnd: INLINE_CLOSE_TITLE_RESERVE }
762
+ : null,
763
+ ]}
764
+ >
636
765
  {config.title}
637
766
  </H1>
638
767
  {config.subtitle ? (