react-native-terra-ui 0.12.0 → 0.13.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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/lib/module/components/accordion/Accordion.js +14 -33
  3. package/lib/module/components/accordion/Accordion.js.map +1 -1
  4. package/lib/module/components/accordion/parts/AccordionContent.js +11 -15
  5. package/lib/module/components/accordion/parts/AccordionContent.js.map +1 -1
  6. package/lib/module/components/accordion/parts/AccordionTrigger.js +5 -5
  7. package/lib/module/components/accordion/parts/AccordionTrigger.js.map +1 -1
  8. package/lib/module/components/avatar/Avatar.js +15 -3
  9. package/lib/module/components/avatar/Avatar.js.map +1 -1
  10. package/lib/module/components/badge/Badge.js +31 -22
  11. package/lib/module/components/badge/Badge.js.map +1 -1
  12. package/lib/module/components/badge/utils.js +12 -3
  13. package/lib/module/components/badge/utils.js.map +1 -1
  14. package/lib/module/components/chip/Chip.js +1 -0
  15. package/lib/module/components/chip/Chip.js.map +1 -1
  16. package/lib/module/components/header/parts/HeaderBar.js +91 -82
  17. package/lib/module/components/header/parts/HeaderBar.js.map +1 -1
  18. package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js +50 -33
  19. package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js.map +1 -1
  20. package/lib/module/components/input/parts/InputField.js +96 -82
  21. package/lib/module/components/input/parts/InputField.js.map +1 -1
  22. package/lib/module/components/text/Text.js +10 -6
  23. package/lib/module/components/text/Text.js.map +1 -1
  24. package/lib/module/theme/tokens/dark.js +2 -2
  25. package/lib/module/theme/tokens/dark.js.map +1 -1
  26. package/lib/module/theme/tokens/light.js +2 -2
  27. package/lib/module/theme/tokens/light.js.map +1 -1
  28. package/lib/module/theme/tokens/primitives.js +24 -4
  29. package/lib/module/theme/tokens/primitives.js.map +1 -1
  30. package/lib/typescript/src/components/accordion/Accordion.d.ts.map +1 -1
  31. package/lib/typescript/src/components/accordion/index.d.ts +1 -1
  32. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts.map +1 -1
  34. package/lib/typescript/src/components/accordion/types.d.ts +0 -7
  35. package/lib/typescript/src/components/accordion/types.d.ts.map +1 -1
  36. package/lib/typescript/src/components/avatar/Avatar.d.ts.map +1 -1
  37. package/lib/typescript/src/components/badge/Badge.d.ts.map +1 -1
  38. package/lib/typescript/src/components/badge/utils.d.ts +6 -0
  39. package/lib/typescript/src/components/badge/utils.d.ts.map +1 -1
  40. package/lib/typescript/src/components/input/parts/InputField.d.ts.map +1 -1
  41. package/lib/typescript/src/theme/tokens/primitives.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/accordion/Accordion.tsx +5 -19
  44. package/src/components/accordion/index.ts +0 -1
  45. package/src/components/accordion/parts/AccordionContent.tsx +14 -12
  46. package/src/components/accordion/parts/AccordionTrigger.tsx +3 -3
  47. package/src/components/accordion/types.ts +0 -8
  48. package/src/components/avatar/Avatar.tsx +12 -2
  49. package/src/components/badge/Badge.tsx +25 -15
  50. package/src/components/badge/utils.ts +12 -3
  51. package/src/components/chip/Chip.tsx +1 -0
  52. package/src/components/header/parts/HeaderBar.tsx +80 -70
  53. package/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.tsx +47 -29
  54. package/src/components/input/parts/InputField.tsx +63 -55
  55. package/src/components/text/Text.tsx +10 -6
  56. package/src/theme/tokens/dark.ts +2 -2
  57. package/src/theme/tokens/light.ts +2 -2
  58. package/src/theme/tokens/primitives.ts +24 -4
@@ -24,6 +24,15 @@ export function hasLabel(label: string | number | undefined): label is string |
24
24
  return label !== undefined && label !== "";
25
25
  }
26
26
 
27
+ /**
28
+ * `true` when the label is one character, which the badge draws as a circle
29
+ * instead of a pill. Counts code points so a single emoji or astral character
30
+ * still counts as one.
31
+ */
32
+ export function isSingleCharacter(label: string | number): boolean {
33
+ return Array.from(String(label)).length === 1;
34
+ }
35
+
27
36
  /**
28
37
  * Outer diameter of the dot per size. The badge's own stylesheet repeats
29
38
  * these as literals — the unistyles Babel plugin reads that object statically,
@@ -31,9 +40,9 @@ export function hasLabel(label: string | number | undefined): label is string |
31
40
  * test suite is what keeps the two in step.
32
41
  */
33
42
  export const DOT_SIZE: Record<BadgeSize, number> = {
34
- sm: 12,
35
- md: 16,
36
- lg: 20,
43
+ sm: 10,
44
+ md: 12,
45
+ lg: 14,
37
46
  };
38
47
 
39
48
  export interface AnchorPosition {
@@ -293,6 +293,7 @@ const styles = StyleSheet.create((theme) => {
293
293
  // safe here even though they would resolve correctly under test.
294
294
  label: {
295
295
  paddingHorizontal: 2,
296
+ includeFontPadding: false,
296
297
  fontFamily: isSystemLabelFont ? undefined : labelFont,
297
298
  fontWeight: isSystemLabelFont ? FONT_WEIGHT_VALUE.semibold : undefined,
298
299
  variants: {
@@ -5,7 +5,7 @@ import Animated from "react-native-reanimated";
5
5
  import { ScrollToTopGuard } from "react-native-screens/experimental";
6
6
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
7
7
 
8
- import { FONT_WEIGHT_VALUE } from "#utils/typography";
8
+ import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
9
9
  import { resolveScreenPadding } from "../../../theme/screen-padding";
10
10
  import { Toolbar } from "../../toolbar";
11
11
  import { HeaderDismissButton, type HeaderDismissProps } from "../header-buttons";
@@ -112,79 +112,89 @@ export function HeaderBar({
112
112
  );
113
113
  }
114
114
 
115
- const styles = StyleSheet.create((theme, runtime) => ({
116
- windowInsets: (left: number, right: number) => ({
117
- paddingLeft: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, left),
118
- paddingRight: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, right),
119
- }),
120
- // A centered title spans the region between the window controls and is inset
121
- // by the same amount from both of its edges, so it stays centered in that
122
- // region. The inset is whichever side reaches further in — bar padding plus
123
- // that side's toolbar — so a long title truncates before either toolbar
124
- // instead of running underneath it. A left-aligned title is in the flex row
125
- // already and needs none of this.
126
- titleInsets: (
127
- left: number,
128
- right: number,
129
- leadingWidth: number,
130
- trailingWidth: number,
131
- alignment: "center" | "left"
132
- ) => {
133
- if (alignment !== "center") return {};
134
- const padding = resolveScreenPadding(theme, runtime.breakpoint).x;
135
- const gap = theme.spacing["3"];
136
- const reachLeft = Math.max(padding, left) + (leadingWidth > 0 ? leadingWidth + gap : 0) - left;
137
- const reachRight = Math.max(padding, right) + (trailingWidth > 0 ? trailingWidth + gap : 0) - right;
138
- const inset = Math.max(reachLeft, reachRight);
139
- return { left: left + inset, right: right + inset };
140
- },
141
- bar: {
142
- height: theme.layout.header.height,
143
- flexDirection: "row",
144
- alignItems: "center",
145
- paddingHorizontal: theme.layout.screen.padding.x,
146
- gap: theme.spacing["3"],
147
- justifyContent: "space-between",
148
- },
149
- // A centered title is absolutely positioned, so it leaves the flex row. With
150
- // no leading slot the trailing toolbar would be the only in-flow child and
151
- // `space-between` would park it on the left — the auto margin pins it right.
152
- trailing: {
153
- justifyContent: "flex-end",
154
- variants: {
155
- titleAlignment: {
156
- center: { marginLeft: "auto" },
157
- left: {},
158
- },
115
+ const styles = StyleSheet.create((theme, runtime) => {
116
+ // Resolved inline: the unistyles Babel plugin rewrites this factory, so an
117
+ // imported helper called from inside it is not in scope at runtime.
118
+ const titleWeight = theme.typography.variants["title-md"].fontWeight;
119
+ const titleFont = theme.typography.fonts[titleWeight];
120
+ const isSystemTitleFont = titleFont === SYSTEM_FONT;
121
+
122
+ return {
123
+ windowInsets: (left: number, right: number) => ({
124
+ paddingLeft: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, left),
125
+ paddingRight: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, right),
126
+ }),
127
+ // A centered title spans the region between the window controls and is inset
128
+ // by the same amount from both of its edges, so it stays centered in that
129
+ // region. The inset is whichever side reaches further in — bar padding plus
130
+ // that side's toolbar — so a long title truncates before either toolbar
131
+ // instead of running underneath it. A left-aligned title is in the flex row
132
+ // already and needs none of this.
133
+ titleInsets: (
134
+ left: number,
135
+ right: number,
136
+ leadingWidth: number,
137
+ trailingWidth: number,
138
+ alignment: "center" | "left"
139
+ ) => {
140
+ if (alignment !== "center") return {};
141
+ const padding = resolveScreenPadding(theme, runtime.breakpoint).x;
142
+ const gap = theme.spacing["3"];
143
+ const reachLeft = Math.max(padding, left) + (leadingWidth > 0 ? leadingWidth + gap : 0) - left;
144
+ const reachRight = Math.max(padding, right) + (trailingWidth > 0 ? trailingWidth + gap : 0) - right;
145
+ const inset = Math.max(reachLeft, reachRight);
146
+ return { left: left + inset, right: right + inset };
147
+ },
148
+ bar: {
149
+ height: theme.layout.header.height,
150
+ flexDirection: "row",
151
+ alignItems: "center",
152
+ paddingHorizontal: theme.layout.screen.padding.x,
153
+ gap: theme.spacing["3"],
154
+ justifyContent: "space-between",
159
155
  },
160
- },
161
- titleContainer: {
162
- variants: {
163
- titleAlignment: {
164
- center: {
165
- position: "absolute",
166
- left: 0,
167
- right: 0,
168
- alignItems: "center",
156
+ // A centered title is absolutely positioned, so it leaves the flex row. With
157
+ // no leading slot the trailing toolbar would be the only in-flow child and
158
+ // `space-between` would park it on the left — the auto margin pins it right.
159
+ trailing: {
160
+ justifyContent: "flex-end",
161
+ variants: {
162
+ titleAlignment: {
163
+ center: { marginLeft: "auto" },
164
+ left: {},
169
165
  },
170
- left: {
171
- flex: 1,
172
- alignItems: "flex-start",
166
+ },
167
+ },
168
+ titleContainer: {
169
+ variants: {
170
+ titleAlignment: {
171
+ center: {
172
+ position: "absolute",
173
+ left: 0,
174
+ right: 0,
175
+ alignItems: "center",
176
+ },
177
+ left: {
178
+ flex: 1,
179
+ alignItems: "flex-start",
180
+ },
173
181
  },
174
182
  },
175
183
  },
176
- },
177
- title: {
178
- fontSize: theme.typography.variants["title-md"].fontSize,
179
- lineHeight: theme.typography.variants["title-md"].lineHeight,
180
- letterSpacing: theme.typography.variants["title-md"].letterSpacing,
181
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["title-md"].fontWeight],
182
- color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
183
- variants: {
184
- titleAlignment: {
185
- center: { textAlign: "center" },
186
- left: { textAlign: "left" },
184
+ title: {
185
+ fontSize: theme.typography.variants["title-md"].fontSize,
186
+ lineHeight: theme.typography.variants["title-md"].lineHeight,
187
+ letterSpacing: theme.typography.variants["title-md"].letterSpacing,
188
+ fontFamily: isSystemTitleFont ? undefined : titleFont,
189
+ fontWeight: isSystemTitleFont ? FONT_WEIGHT_VALUE[titleWeight] : undefined,
190
+ includeFontPadding: false,
191
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
192
+ variants: {
193
+ titleAlignment: {
194
+ center: { textAlign: "center" },
195
+ left: { textAlign: "left" },
196
+ },
187
197
  },
188
198
  },
189
- },
190
- }));
199
+ };
200
+ });
@@ -4,7 +4,7 @@ import { type LayoutChangeEvent, Text } from "react-native";
4
4
  import Animated from "react-native-reanimated";
5
5
  import { StyleSheet, useUnistyles } from "react-native-unistyles";
6
6
 
7
- import { FONT_WEIGHT_VALUE } from "#utils/typography";
7
+ import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
8
8
  import { isTextTitle } from "../../../utils";
9
9
  import type { LargeTitleHeaderProps } from "..";
10
10
 
@@ -58,31 +58,49 @@ export function LargeTitleContent({
58
58
  );
59
59
  }
60
60
 
61
- const styles = StyleSheet.create((theme) => ({
62
- portalContent: (hasAccessoryBelow: boolean) => ({
63
- gap: theme.spacing["1"] / 2,
64
- // Include the gap in the measured height used to position the accessory.
65
- paddingBottom: hasAccessoryBelow ? theme.spacing["2"] : 0,
66
- paddingHorizontal: theme.layout.screen.padding.x,
67
- }),
68
- overline: {
69
- fontSize: theme.typography.variants["label-sm"].fontSize,
70
- lineHeight: theme.typography.variants["label-sm"].lineHeight,
71
- letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
72
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["label-sm"].fontWeight],
73
- color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
74
- },
75
- largeTitle: {
76
- fontSize: theme.typography.variants["headline-lg"].fontSize,
77
- lineHeight: theme.typography.variants["headline-lg"].lineHeight,
78
- letterSpacing: theme.typography.variants["headline-lg"].letterSpacing,
79
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["headline-lg"].fontWeight],
80
- color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
81
- },
82
- subtitle: {
83
- fontSize: theme.typography.variants["body-lg"].fontSize,
84
- lineHeight: theme.typography.variants["body-lg"].lineHeight,
85
- letterSpacing: theme.typography.variants["body-lg"].letterSpacing,
86
- color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
87
- },
88
- }));
61
+ const styles = StyleSheet.create((theme) => {
62
+ // Resolved inline: the unistyles Babel plugin rewrites this factory, so an
63
+ // imported helper called from inside it is not in scope at runtime.
64
+ const overlineWeight = theme.typography.variants["label-sm"].fontWeight;
65
+ const overlineFont = theme.typography.fonts[overlineWeight];
66
+ const isSystemOverlineFont = overlineFont === SYSTEM_FONT;
67
+ const titleWeight = theme.typography.variants["headline-lg"].fontWeight;
68
+ const titleFont = theme.typography.fonts[titleWeight];
69
+ const isSystemTitleFont = titleFont === SYSTEM_FONT;
70
+ const subtitleWeight = theme.typography.variants["body-lg"].fontWeight;
71
+ const subtitleFont = theme.typography.fonts[subtitleWeight];
72
+ const isSystemSubtitleFont = subtitleFont === SYSTEM_FONT;
73
+
74
+ return {
75
+ portalContent: (hasAccessoryBelow: boolean) => ({
76
+ gap: theme.spacing["1"] / 2,
77
+ // Include the gap in the measured height used to position the accessory.
78
+ paddingBottom: hasAccessoryBelow ? theme.spacing["2"] : 0,
79
+ paddingHorizontal: theme.layout.screen.padding.x,
80
+ }),
81
+ overline: {
82
+ fontSize: theme.typography.variants["label-sm"].fontSize,
83
+ lineHeight: theme.typography.variants["label-sm"].lineHeight,
84
+ letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
85
+ fontFamily: isSystemOverlineFont ? undefined : overlineFont,
86
+ fontWeight: isSystemOverlineFont ? FONT_WEIGHT_VALUE[overlineWeight] : undefined,
87
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
88
+ },
89
+ largeTitle: {
90
+ fontSize: theme.typography.variants["headline-lg"].fontSize,
91
+ lineHeight: theme.typography.variants["headline-lg"].lineHeight,
92
+ letterSpacing: theme.typography.variants["headline-lg"].letterSpacing,
93
+ fontFamily: isSystemTitleFont ? undefined : titleFont,
94
+ fontWeight: isSystemTitleFont ? FONT_WEIGHT_VALUE[titleWeight] : undefined,
95
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
96
+ },
97
+ subtitle: {
98
+ fontSize: theme.typography.variants["body-lg"].fontSize,
99
+ lineHeight: theme.typography.variants["body-lg"].lineHeight,
100
+ letterSpacing: theme.typography.variants["body-lg"].letterSpacing,
101
+ fontFamily: isSystemSubtitleFont ? undefined : subtitleFont,
102
+ fontWeight: isSystemSubtitleFont ? FONT_WEIGHT_VALUE[subtitleWeight] : undefined,
103
+ color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
104
+ },
105
+ };
106
+ });
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
5
5
 
6
6
  import type { ColorToken, RadiusKey } from "#theme/types";
7
7
  import { resolveThemeColor } from "#utils/resolve-theme-color";
8
- import { FONT_WEIGHT_VALUE } from "#utils/typography";
8
+ import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
9
9
  import type { InputBaseProps, InputSize } from "../types";
10
10
 
11
11
  /** Typographic role of the text, per size. */
@@ -68,6 +68,9 @@ export const InputField = forwardRef<ComponentRef<typeof TextInput>, InputBasePr
68
68
  editable={!isDisabled}
69
69
  placeholderTextColor={theme.color["text.tertiary"]}
70
70
  style={styles.input}
71
+ // The container height is fixed per size, so scaling stops where the
72
+ // body role does rather than growing past the box.
73
+ maxFontSizeMultiplier={theme.typography.variants[TEXT_VARIANT[size]].maxFontSizeMultiplier}
71
74
  onFocus={handleFocus}
72
75
  onBlur={handleBlur}
73
76
  {...rest}
@@ -77,62 +80,67 @@ export const InputField = forwardRef<ComponentRef<typeof TextInput>, InputBasePr
77
80
  );
78
81
  });
79
82
 
80
- const styles = StyleSheet.create((theme) => ({
81
- container: {
82
- flexDirection: "row",
83
- alignItems: "center",
84
- borderWidth: StyleSheet.hairlineWidth,
85
- borderColor: theme.color["border.subtle"],
86
- variants: {
87
- size: {
88
- sm: { height: 36, paddingHorizontal: theme.spacing["4"], gap: 6 },
89
- md: { height: 44, paddingHorizontal: theme.spacing["4"], gap: 6 },
90
- lg: { height: 52, paddingHorizontal: theme.spacing["4"], gap: 6 },
91
- },
92
- focused: {
93
- true: { borderColor: theme.color["accent.border"] },
94
- false: {},
95
- },
96
- invalid: {
97
- true: { borderColor: theme.color["danger.text"] },
98
- false: {},
99
- },
100
- disabled: {
101
- true: { opacity: theme.opacity.disabled },
102
- false: {},
103
- },
104
- },
105
- },
106
- // `background` is an open `ColorToken` and `radius` an eight-step scale, so
107
- // both resolve in a dynamic function rather than enumerating as variants.
108
- fill: (background: ColorToken, radius: RadiusKey) => ({
109
- backgroundColor: resolveThemeColor(background, theme),
110
- borderRadius: theme.radius[radius],
111
- }),
112
- input: {
113
- flex: 1,
114
- // Android centres text in a taller box by default; zeroing the padding lets
115
- // the container's own height own the geometry on both platforms.
116
- paddingVertical: 0,
117
- color: theme.color["text.primary"],
118
- variants: {
119
- size: {
120
- sm: {
121
- fontSize: theme.typography.variants[TEXT_VARIANT.sm].fontSize,
122
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.sm].fontWeight],
83
+ const styles = StyleSheet.create((theme) => {
84
+ // Resolved inline rather than through a helper: the unistyles Babel plugin
85
+ // rewrites this factory, and an imported function called from inside it is
86
+ // not in scope at runtime. Every body role shares one weight, so one lookup
87
+ // covers all three sizes.
88
+ const bodyWeight = theme.typography.variants[TEXT_VARIANT.md].fontWeight;
89
+ const bodyFont = theme.typography.fonts[bodyWeight];
90
+ const isSystemBodyFont = bodyFont === SYSTEM_FONT;
91
+
92
+ return {
93
+ container: {
94
+ flexDirection: "row",
95
+ alignItems: "center",
96
+ borderWidth: StyleSheet.hairlineWidth,
97
+ borderColor: theme.color["border.subtle"],
98
+ variants: {
99
+ size: {
100
+ sm: { height: 36, paddingHorizontal: theme.spacing["4"], gap: 6 },
101
+ md: { height: 44, paddingHorizontal: theme.spacing["4"], gap: 6 },
102
+ lg: { height: 52, paddingHorizontal: theme.spacing["4"], gap: 6 },
123
103
  },
124
- md: {
125
- fontSize: theme.typography.variants[TEXT_VARIANT.md].fontSize,
126
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.md].fontWeight],
104
+ focused: {
105
+ true: { borderColor: theme.color["accent.border"] },
106
+ false: {},
127
107
  },
128
- lg: {
129
- fontSize: theme.typography.variants[TEXT_VARIANT.lg].fontSize,
130
- fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants[TEXT_VARIANT.lg].fontWeight],
108
+ invalid: {
109
+ true: { borderColor: theme.color["danger.text"] },
110
+ false: {},
111
+ },
112
+ disabled: {
113
+ true: { opacity: theme.opacity.disabled },
114
+ false: {},
131
115
  },
132
116
  },
133
- focused: { true: {}, false: {} },
134
- invalid: { true: {}, false: {} },
135
- disabled: { true: {}, false: {} },
136
117
  },
137
- },
138
- }));
118
+ // `background` is an open `ColorToken` and `radius` an eight-step scale, so
119
+ // both resolve in a dynamic function rather than enumerating as variants.
120
+ fill: (background: ColorToken, radius: RadiusKey) => ({
121
+ backgroundColor: resolveThemeColor(background, theme),
122
+ borderRadius: theme.radius[radius],
123
+ }),
124
+ input: {
125
+ flex: 1,
126
+ // Android centres text in a taller box by default; zeroing the padding lets
127
+ // the container's own height own the geometry on both platforms.
128
+ paddingVertical: 0,
129
+ color: theme.color["text.primary"],
130
+ // Custom fonts encode weight in the family name; the system font takes a
131
+ // numeric weight instead.
132
+ fontFamily: isSystemBodyFont ? undefined : bodyFont,
133
+ fontWeight: isSystemBodyFont ? FONT_WEIGHT_VALUE[bodyWeight] : undefined,
134
+ variants: {
135
+ size: {
136
+ sm: { fontSize: theme.typography.variants[TEXT_VARIANT.sm].fontSize },
137
+ md: { fontSize: theme.typography.variants[TEXT_VARIANT.md].fontSize },
138
+ lg: { fontSize: theme.typography.variants[TEXT_VARIANT.lg].fontSize },
139
+ },
140
+ focused: { true: {}, false: {} },
141
+ invalid: { true: {}, false: {} },
142
+ disabled: { true: {}, false: {} },
143
+ },
144
+ },
145
+ };
146
+ });
@@ -19,10 +19,10 @@ export type TextAlign = "left" | "center" | "right" | "justify";
19
19
  const HEADING_VARIANT_ALIAS: Record<HeadingTextVariant, RoleTextVariant> = {
20
20
  h1: "headline-lg",
21
21
  h2: "headline-md",
22
- h3: "title-lg",
23
- h4: "title-md",
24
- h5: "title-sm",
25
- h6: "label-lg",
22
+ h3: "headline-sm",
23
+ h4: "title-lg",
24
+ h5: "title-md",
25
+ h6: "title-sm",
26
26
  };
27
27
 
28
28
  function resolveTextVariant(variant: TextVariant): RoleTextVariant {
@@ -116,11 +116,15 @@ export const Text = forwardRef<ComponentRef<typeof RNText>, TextProps>(function
116
116
 
117
117
  // ─── Styles ───────────────────────────────────────────────────────────────────
118
118
  //
119
- // Text has no static style of its own — this stylesheet exists so the
119
+ // Besides the Android font-padding reset, this stylesheet exists so the
120
120
  // Unistyles Babel plugin swaps the `RNText` imported above for its Unistyles-aware
121
121
  // counterpart, which registers the ShadowNode with the native side. That is what
122
122
  // keeps a Unistyles style passed in through `style` updating on theme change;
123
123
  // without it the style freezes at whichever theme was active when it was
124
124
  // created, and only callers that happen to subscribe to the theme themselves
125
125
  // would appear to work.
126
- const styles = StyleSheet.create(() => ({ root: {} }));
126
+ const styles = StyleSheet.create(() => ({
127
+ // Android pads glyphs with the font's ascender/descender by default, which
128
+ // drops text below centre inside fixed-height controls. iOS never adds it.
129
+ root: { includeFontPadding: false },
130
+ }));
@@ -80,8 +80,8 @@ export const dark = {
80
80
  // Danger
81
81
  "color.danger.text": "{palette.red.400}",
82
82
 
83
- "color.danger.solid.bg": "{palette.red.400}",
84
- "color.danger.solid.fg": "{palette.slate.950}",
83
+ "color.danger.solid.bg": "#dc0010",
84
+ "color.danger.solid.fg": "{palette.white}",
85
85
  "color.danger.solid.border": "{palette.red.800}",
86
86
 
87
87
  "color.danger.soft.bg": "{palette.red.950}",
@@ -79,8 +79,8 @@ export const light = {
79
79
  // Danger
80
80
  "color.danger.text": "{palette.rose.700}",
81
81
 
82
- "color.danger.solid.bg": "{palette.red.400}",
83
- "color.danger.solid.fg": "{palette.slate.950}",
82
+ "color.danger.solid.bg": "#dc0010",
83
+ "color.danger.solid.fg": "{palette.white}",
84
84
  "color.danger.solid.border": "{palette.red.300}",
85
85
 
86
86
  "color.danger.soft.bg": "{palette.red.100}",
@@ -371,6 +371,26 @@ export const primitives = {
371
371
  "typography.fonts.bold": "System",
372
372
 
373
373
  // ── typography: role variants ─────────────────────────────────────────────
374
+ // Base sizes in React Native logical units, before OS text scaling; these
375
+ // are not physical pixels. Pass them directly to Text without multiplying
376
+ // by PixelRatio or fontScale (native Text handles accessibility scaling).
377
+ // Each role pairs fontSize with an absolute lineHeight, not a ratio.
378
+ // Line heights use a 4-unit grid; font sizes need not follow that grid.
379
+ //
380
+ // Font size / line height, listed from lg to sm:
381
+ // display: 40/48, 36/44, 32/40 — short, prominent hero text.
382
+ // headline: 28/36, 24/32, 22/28 — page and section headings.
383
+ // title: 20/28, 18/24, 16/24 — card, list, and navigation titles.
384
+ // body: 18/28, 16/24, 14/20 — reading text; body-md is Text's default.
385
+ // label: 16/20, 14/20, 12/16 — short control labels, not paragraphs.
386
+ // caption: 12/16 — brief supplementary text.
387
+ // Prefer body sizes for essential information; reserve 12 for compact text.
388
+ // These are Terra's custom roles, not native platform text-style presets.
389
+ //
390
+ // maxFontSizeMultiplier limits accessibility enlargement when scaling is
391
+ // enabled. The caps below are layout tradeoffs, not accessibility targets.
392
+ // RN uses 0 for no cap; Terra Text also accepts a per-instance override.
393
+ // Validate wrapping and clipping at large OS text sizes and with custom fonts.
374
394
  "typography.variants.display-lg.fontSize": 40,
375
395
  "typography.variants.display-lg.lineHeight": 48,
376
396
  "typography.variants.display-lg.fontWeight": "bold",
@@ -412,12 +432,12 @@ export const primitives = {
412
432
  "typography.variants.title-md.letterSpacing": 0,
413
433
  "typography.variants.title-md.maxFontSizeMultiplier": 1.4,
414
434
  "typography.variants.title-sm.fontSize": 16,
415
- "typography.variants.title-sm.lineHeight": 22,
435
+ "typography.variants.title-sm.lineHeight": 24,
416
436
  "typography.variants.title-sm.fontWeight": "semibold",
417
437
  "typography.variants.title-sm.letterSpacing": 0,
418
438
  "typography.variants.title-sm.maxFontSizeMultiplier": 1.4,
419
439
  "typography.variants.body-lg.fontSize": 18,
420
- "typography.variants.body-lg.lineHeight": 26,
440
+ "typography.variants.body-lg.lineHeight": 28,
421
441
  "typography.variants.body-lg.fontWeight": "regular",
422
442
  "typography.variants.body-lg.letterSpacing": 0,
423
443
  "typography.variants.body-lg.maxFontSizeMultiplier": 1.8,
@@ -437,7 +457,7 @@ export const primitives = {
437
457
  "typography.variants.label-lg.letterSpacing": 0.1,
438
458
  "typography.variants.label-lg.maxFontSizeMultiplier": 1.5,
439
459
  "typography.variants.label-md.fontSize": 14,
440
- "typography.variants.label-md.lineHeight": 18,
460
+ "typography.variants.label-md.lineHeight": 20,
441
461
  "typography.variants.label-md.fontWeight": "medium",
442
462
  "typography.variants.label-md.letterSpacing": 0.1,
443
463
  "typography.variants.label-md.maxFontSizeMultiplier": 1.5,
@@ -450,7 +470,7 @@ export const primitives = {
450
470
  "typography.variants.caption.lineHeight": 16,
451
471
  "typography.variants.caption.fontWeight": "regular",
452
472
  "typography.variants.caption.letterSpacing": 0.2,
453
- "typography.variants.caption.maxFontSizeMultiplier": 1.4,
473
+ "typography.variants.caption.maxFontSizeMultiplier": 2,
454
474
 
455
475
  // ── component opacity multipliers ─────────────────────────────────────────────────
456
476
  "opacity.disabled": 0.45,