@compsych/mobile-ui 1.0.11 → 1.0.13

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActionSheet/index.tsx +124 -57
  3. package/src/components/Alert/index.tsx +19 -10
  4. package/src/components/Avatar/index.tsx +95 -92
  5. package/src/components/Badge/index.tsx +39 -36
  6. package/src/components/BodyText/index.tsx +5 -5
  7. package/src/components/Breadcrumb/index.tsx +32 -30
  8. package/src/components/Button/Button.test.tsx +13 -7
  9. package/src/components/Button/index.tsx +114 -92
  10. package/src/components/ChatBubble/ChatBubble.test.tsx +77 -0
  11. package/src/components/ChatBubble/index.tsx +187 -0
  12. package/src/components/ChatInput/ChatInput.test.tsx +90 -0
  13. package/src/components/ChatInput/index.tsx +263 -0
  14. package/src/components/Checkbox/index.tsx +24 -21
  15. package/src/components/Chip/index.tsx +75 -72
  16. package/src/components/Divider/index.tsx +3 -3
  17. package/src/components/EmptyState/index.tsx +72 -55
  18. package/src/components/HeaderText/index.tsx +5 -5
  19. package/src/components/Input/index.tsx +40 -37
  20. package/src/components/List/index.tsx +2 -3
  21. package/src/components/Pagination/index.tsx +56 -43
  22. package/src/components/PlanCard/index.tsx +118 -50
  23. package/src/components/ProgressTracker/index.tsx +44 -26
  24. package/src/components/PromotionCard/index.tsx +217 -212
  25. package/src/components/RadioButton/index.tsx +27 -25
  26. package/src/components/ScreenContainer/ScreenContainer.test.tsx +12 -2
  27. package/src/components/ScreenContainer/index.tsx +21 -10
  28. package/src/components/SegmentedControl/index.tsx +37 -30
  29. package/src/components/SelectionCard/index.tsx +38 -26
  30. package/src/components/ServiceCard/index.tsx +162 -130
  31. package/src/components/Slider/index.tsx +77 -77
  32. package/src/components/Snackbar/index.tsx +69 -57
  33. package/src/components/Switch/index.tsx +13 -12
  34. package/src/components/Tooltip/index.tsx +3 -3
  35. package/src/index.ts +13 -0
  36. package/src/theme/ThemeContext.ts +11 -0
  37. package/src/theme/ThemeProvider.tsx +40 -0
  38. package/src/theme/__tests__/ThemeProvider.test.tsx +101 -0
  39. package/src/theme/__tests__/defaultTheme.test.ts +36 -0
  40. package/src/theme/__tests__/mergeTheme.test.ts +94 -0
  41. package/src/theme/defaultTheme.ts +15 -0
  42. package/src/theme/index.ts +8 -0
  43. package/src/theme/mergeTheme.ts +47 -0
  44. package/src/theme/types.ts +39 -0
  45. package/src/theme/useTheme.ts +16 -0
@@ -1,4 +1,10 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
 
3
9
  import {
4
10
  LayoutChangeEvent,
@@ -10,7 +16,7 @@ import {
10
16
  ViewStyle,
11
17
  } from 'react-native';
12
18
 
13
- import { sys } from '../../tokens';
19
+ import { useTheme } from '../../theme';
14
20
 
15
21
  export interface SliderProps {
16
22
  /** Controlled value */
@@ -34,8 +40,6 @@ export interface SliderProps {
34
40
  style?: StyleProp<ViewStyle>;
35
41
  }
36
42
 
37
- const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
38
-
39
43
  // ── Layout constants ──────────────────────────────────────────────────────────
40
44
  const TRACK_HEIGHT = 8;
41
45
  const THUMB_SIZE = 24;
@@ -67,6 +71,8 @@ export function Slider({
67
71
  accessibilityLabel,
68
72
  style,
69
73
  }: SliderProps) {
74
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = useTheme();
75
+
70
76
  const isControlled = valueProp !== undefined;
71
77
  const [internalValue, setInternalValue] = useState<number>(
72
78
  defaultValue ?? min,
@@ -181,10 +187,69 @@ export function Slider({
181
187
  elevation: 1,
182
188
  };
183
189
 
190
+ // ── Token-derived styles ──────────────────────────────────────────────────────
191
+ const tokenStyles = useMemo(
192
+ () => ({
193
+ root: {
194
+ gap: dim.spacing.padding.sysPadding8,
195
+ alignSelf: 'stretch' as const,
196
+ },
197
+ label: {
198
+ color: cr.surface.surface.sysOnSurface,
199
+ fontSize: ts.labelMedium.sysFontSize,
200
+ lineHeight: ts.labelMedium.sysLineHeight,
201
+ letterSpacing: ts.labelMedium.sysTracking,
202
+ fontWeight: '400' as const,
203
+ includeFontPadding: false,
204
+ },
205
+ row: {
206
+ flexDirection: 'row' as const,
207
+ alignItems: 'center' as const,
208
+ gap: dim.spacing.padding.sysPadding8,
209
+ },
210
+ rangeLabel: {
211
+ color: cr.surface.surface.sysOnSurfaceVariant,
212
+ fontSize: ts.bodySmall.sysFontSize,
213
+ lineHeight: ts.bodySmall.sysLineHeight,
214
+ fontWeight: '400' as const,
215
+ includeFontPadding: false,
216
+ flexShrink: 0,
217
+ },
218
+ trackBg: {
219
+ position: 'absolute' as const,
220
+ top: TRACK_TOP,
221
+ left: 0,
222
+ right: 0,
223
+ height: TRACK_HEIGHT,
224
+ borderRadius: 9999,
225
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
226
+ },
227
+ trackFill: {
228
+ position: 'absolute' as const,
229
+ top: TRACK_TOP,
230
+ left: 0,
231
+ height: TRACK_HEIGHT,
232
+ borderRadius: 9999,
233
+ backgroundColor: cr.accent.primary.sysPrimary,
234
+ },
235
+ thumb: {
236
+ position: 'absolute' as const,
237
+ top: THUMB_TOP,
238
+ width: THUMB_SIZE,
239
+ height: THUMB_SIZE,
240
+ borderRadius: 9999,
241
+ backgroundColor: cr.surface.surface.sysSurface,
242
+ alignItems: 'center' as const,
243
+ justifyContent: 'center' as const,
244
+ },
245
+ }),
246
+ [cr, dim, ts],
247
+ );
248
+
184
249
  // ── Render ────────────────────────────────────────────────────────────────────
185
250
  return (
186
251
  <View
187
- style={[styles.root, style]}
252
+ style={[tokenStyles.root, style]}
188
253
  accessible
189
254
  accessibilityRole="adjustable"
190
255
  accessibilityLabel={accessibilityLabel ?? label}
@@ -202,12 +267,12 @@ export function Slider({
202
267
  onBlur={() => setFocused(false)}
203
268
  >
204
269
  {/* ── Optional label ────────────────────────────────────────────────── */}
205
- {label && <Text style={styles.label}>{label}</Text>}
270
+ {label && <Text style={tokenStyles.label}>{label}</Text>}
206
271
 
207
272
  {/* ── Slider row ────────────────────────────────────────────────────── */}
208
- <View style={[styles.row, disabled && styles.rowDisabled]}>
273
+ <View style={[tokenStyles.row, disabled && styles.rowDisabled]}>
209
274
  {/* Min label */}
210
- {showMinMax && <Text style={styles.rangeLabel}>{min}</Text>}
275
+ {showMinMax && <Text style={tokenStyles.rangeLabel}>{min}</Text>}
211
276
 
212
277
  {/* ── Track container — the gesture zone ──────────────────────────── */}
213
278
  <View
@@ -216,13 +281,13 @@ export function Slider({
216
281
  {...panResponder.panHandlers}
217
282
  >
218
283
  {/* Track background (unfilled — full width) */}
219
- <View style={styles.trackBg} />
284
+ <View style={tokenStyles.trackBg} />
220
285
 
221
286
  {/* Track fill (filled — up to thumb center) */}
222
- <View style={[styles.trackFill, { width: fillWidth }]} />
287
+ <View style={[tokenStyles.trackFill, { width: fillWidth }]} />
223
288
 
224
289
  {/* ── Thumb ─────────────────────────────────────────────────────── */}
225
- <View style={[styles.thumb, { left: thumbLeft }, thumbShadow]}>
290
+ <View style={[tokenStyles.thumb, { left: thumbLeft }, thumbShadow]}>
226
291
  {/* Focus ring — 1px outside thumb (26×26 centered over 24×24) */}
227
292
  {focused && (
228
293
  <View
@@ -240,48 +305,17 @@ export function Slider({
240
305
  </View>
241
306
 
242
307
  {/* Max label */}
243
- {showMinMax && <Text style={styles.rangeLabel}>{max}</Text>}
308
+ {showMinMax && <Text style={tokenStyles.rangeLabel}>{max}</Text>}
244
309
  </View>
245
310
  </View>
246
311
  );
247
312
  }
248
313
 
249
314
  const styles = StyleSheet.create({
250
- root: {
251
- gap: dim.spacing.padding.sysPadding8,
252
- alignSelf: 'stretch',
253
- },
254
-
255
- // ── Label ─────────────────────────────────────────────────────────────────
256
- label: {
257
- color: cr.surface.surface.sysOnSurface,
258
- fontSize: ts.labelMedium.sysFontSize,
259
- lineHeight: ts.labelMedium.sysLineHeight,
260
- letterSpacing: ts.labelMedium.sysTracking,
261
- fontWeight: '400',
262
- includeFontPadding: false,
263
- },
264
-
265
- // ── Row ───────────────────────────────────────────────────────────────────
266
- row: {
267
- flexDirection: 'row',
268
- alignItems: 'center',
269
- gap: dim.spacing.padding.sysPadding8,
270
- },
271
315
  rowDisabled: {
272
316
  opacity: 0.48,
273
317
  },
274
318
 
275
- // ── Range labels ──────────────────────────────────────────────────────────
276
- rangeLabel: {
277
- color: cr.surface.surface.sysOnSurfaceVariant,
278
- fontSize: ts.bodySmall.sysFontSize,
279
- lineHeight: ts.bodySmall.sysLineHeight,
280
- fontWeight: '400',
281
- includeFontPadding: false,
282
- flexShrink: 0,
283
- },
284
-
285
319
  // ── Track container ───────────────────────────────────────────────────────
286
320
  trackContainer: {
287
321
  flex: 1,
@@ -291,40 +325,6 @@ const styles = StyleSheet.create({
291
325
  overflow: 'visible',
292
326
  },
293
327
 
294
- // ── Track background ──────────────────────────────────────────────────────
295
- trackBg: {
296
- position: 'absolute',
297
- top: TRACK_TOP,
298
- left: 0,
299
- right: 0,
300
- height: TRACK_HEIGHT,
301
- borderRadius: 9999,
302
- backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
303
- },
304
-
305
- // ── Track fill ────────────────────────────────────────────────────────────
306
- trackFill: {
307
- position: 'absolute',
308
- top: TRACK_TOP,
309
- left: 0,
310
- height: TRACK_HEIGHT,
311
- borderRadius: 9999,
312
- backgroundColor: cr.accent.primary.sysPrimary,
313
- },
314
-
315
- // ── Thumb ─────────────────────────────────────────────────────────────────
316
- thumb: {
317
- position: 'absolute',
318
- top: THUMB_TOP,
319
- width: THUMB_SIZE,
320
- height: THUMB_SIZE,
321
- borderRadius: 9999,
322
- backgroundColor: cr.surface.surface.sysSurface,
323
- // Focus ring is absolutely positioned inside the thumb
324
- alignItems: 'center',
325
- justifyContent: 'center',
326
- },
327
-
328
328
  // ── Focus ring: 1px outside the 24×24 thumb → 26×26 ─────────────────────
329
329
  focusRing: {
330
330
  position: 'absolute',
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef } from 'react';
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
2
 
3
3
  import {
4
4
  Animated,
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  import { Ionicons } from '@expo/vector-icons';
13
13
 
14
- import { sys } from '../../tokens';
14
+ import { useTheme } from '../../theme';
15
15
  import { BodyText } from '../BodyText';
16
16
 
17
17
  export type SnackbarVariant = 'filled' | 'outlined';
@@ -26,8 +26,6 @@ export interface SnackbarProps {
26
26
  style?: StyleProp<ViewStyle>;
27
27
  }
28
28
 
29
- const { colorRoles: cr, dimensions: dim } = sys;
30
-
31
29
  export function Snackbar({
32
30
  visible,
33
31
  message,
@@ -37,6 +35,8 @@ export function Snackbar({
37
35
  onClose,
38
36
  style,
39
37
  }: SnackbarProps) {
38
+ const { colorRoles: cr, dimensions: dim } = useTheme();
39
+
40
40
  const translateY = useRef(new Animated.Value(100)).current;
41
41
  const opacity = useRef(new Animated.Value(0)).current;
42
42
 
@@ -73,9 +73,61 @@ export function Snackbar({
73
73
 
74
74
  const isFilled = variant === 'filled';
75
75
 
76
- const containerStyle = isFilled
77
- ? styles.containerFilled
78
- : styles.containerOutlined;
76
+ const tokenStyles = useMemo(
77
+ () => ({
78
+ wrapper: {
79
+ position: 'absolute' as const,
80
+ bottom: dim.spacing.padding.sysPadding24,
81
+ left: dim.spacing.padding.sysPadding16,
82
+ right: dim.spacing.padding.sysPadding16,
83
+ alignItems: 'center' as const,
84
+ },
85
+ container: {
86
+ flexDirection: 'row' as const,
87
+ alignItems: 'center' as const,
88
+ paddingVertical: dim.spacing.padding.sysPadding12,
89
+ paddingHorizontal: dim.spacing.padding.sysPadding16,
90
+ borderRadius: dim.borderRadius.sysRadiusMd,
91
+ gap: dim.spacing.padding.sysPadding16,
92
+ width: '100%' as const,
93
+ // Elevation/lv3: Figma radius=16 (primary), radius=6 (secondary). RN iOS
94
+ // supports one shadow; use the dominant layer. Android uses elevation.
95
+ shadowColor: '#000',
96
+ shadowOffset: { width: 0, height: 4 },
97
+ shadowOpacity: 0.16,
98
+ shadowRadius: 16,
99
+ elevation: 6,
100
+ },
101
+ containerFilled: {
102
+ backgroundColor: cr.accent.primary.sysPrimaryContainer,
103
+ },
104
+ containerOutlined: {
105
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
106
+ borderWidth: dim.borderWidth.sysStrokeThin,
107
+ borderColor: cr.outline.sysOutlineVariant,
108
+ },
109
+ actionSlot: {
110
+ flexDirection: 'row' as const,
111
+ alignItems: 'center' as const,
112
+ gap: dim.spacing.padding.sysPadding12,
113
+ },
114
+ actionButton: {
115
+ paddingVertical: dim.spacing.padding.sysPadding4,
116
+ },
117
+ closeButton: {
118
+ width: 24,
119
+ height: 24,
120
+ borderRadius: dim.borderRadius.sysRadiusFull,
121
+ alignItems: 'center' as const,
122
+ justifyContent: 'center' as const,
123
+ },
124
+ }),
125
+ [cr, dim],
126
+ );
127
+
128
+ const containerVariantStyle = isFilled
129
+ ? tokenStyles.containerFilled
130
+ : tokenStyles.containerOutlined;
79
131
  const labelColor = isFilled
80
132
  ? cr.accent.primary.sysOnPrimary
81
133
  : cr.surface.surface.sysOnSurface;
@@ -91,10 +143,14 @@ export function Snackbar({
91
143
 
92
144
  return (
93
145
  <Animated.View
94
- style={[styles.wrapper, { opacity, transform: [{ translateY }] }, style]}
146
+ style={[
147
+ tokenStyles.wrapper,
148
+ { opacity, transform: [{ translateY }] },
149
+ style,
150
+ ]}
95
151
  pointerEvents={visible ? 'box-none' : 'none'}
96
152
  >
97
- <View style={[styles.container, containerStyle]}>
153
+ <View style={[tokenStyles.container, containerVariantStyle]}>
98
154
  <BodyText
99
155
  variant="medium"
100
156
  color={labelColor}
@@ -104,18 +160,18 @@ export function Snackbar({
104
160
  {message}
105
161
  </BodyText>
106
162
 
107
- <View style={styles.actionSlot}>
163
+ <View style={tokenStyles.actionSlot}>
108
164
  {actionLabel && onAction && (
109
165
  <Pressable
110
166
  onPress={onAction}
111
167
  accessibilityRole="button"
112
168
  accessibilityLabel={actionLabel}
113
169
  style={({ pressed }) => [
114
- styles.actionButton,
170
+ tokenStyles.actionButton,
115
171
  pressed && styles.pressed,
116
172
  ]}
117
173
  >
118
- <BodyText variant="small" emphasized color={actionColor}>
174
+ <BodyText variant="labelMedium" color={actionColor}>
119
175
  {actionLabel}
120
176
  </BodyText>
121
177
  </Pressable>
@@ -127,7 +183,7 @@ export function Snackbar({
127
183
  accessibilityRole="button"
128
184
  accessibilityLabel="Dismiss"
129
185
  style={({ pressed }) => [
130
- styles.closeButton,
186
+ tokenStyles.closeButton,
131
187
  { backgroundColor: closeBg },
132
188
  pressed && styles.pressed,
133
189
  ]}
@@ -142,53 +198,9 @@ export function Snackbar({
142
198
  }
143
199
 
144
200
  const styles = StyleSheet.create({
145
- wrapper: {
146
- position: 'absolute',
147
- bottom: dim.spacing.padding.sysPadding24,
148
- left: dim.spacing.padding.sysPadding16,
149
- right: dim.spacing.padding.sysPadding16,
150
- alignItems: 'center',
151
- },
152
- container: {
153
- flexDirection: 'row',
154
- alignItems: 'center',
155
- paddingVertical: dim.spacing.padding.sysPadding12,
156
- paddingHorizontal: dim.spacing.padding.sysPadding16,
157
- borderRadius: dim.borderRadius.sysRadiusMd,
158
- gap: dim.spacing.padding.sysPadding16,
159
- width: '100%',
160
- shadowColor: '#000',
161
- shadowOffset: { width: 0, height: 4 },
162
- shadowOpacity: 0.12,
163
- shadowRadius: 12,
164
- elevation: 6,
165
- },
166
- containerFilled: {
167
- backgroundColor: cr.accent.primary.sysPrimaryContainer,
168
- },
169
- containerOutlined: {
170
- backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
171
- borderWidth: dim.borderWidth.sysStrokeThin,
172
- borderColor: cr.outline.sysOutline,
173
- },
174
201
  label: {
175
202
  flex: 1,
176
203
  },
177
- actionSlot: {
178
- flexDirection: 'row',
179
- alignItems: 'center',
180
- gap: dim.spacing.padding.sysPadding12,
181
- },
182
- actionButton: {
183
- paddingVertical: dim.spacing.padding.sysPadding4,
184
- },
185
- closeButton: {
186
- width: 24,
187
- height: 24,
188
- borderRadius: dim.borderRadius.sysRadiusFull,
189
- alignItems: 'center',
190
- justifyContent: 'center',
191
- },
192
204
  pressed: {
193
205
  opacity: 0.7,
194
206
  },
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
 
3
3
  import {
4
4
  Animated,
@@ -9,7 +9,7 @@ import {
9
9
  ViewStyle,
10
10
  } from 'react-native';
11
11
 
12
- import { sys } from '../../tokens';
12
+ import { useTheme } from '../../theme';
13
13
 
14
14
  export interface SwitchProps {
15
15
  /** Controlled value. Omit to use internal state. */
@@ -23,17 +23,10 @@ export interface SwitchProps {
23
23
  style?: StyleProp<ViewStyle>;
24
24
  }
25
25
 
26
- const { colorRoles: cr, dimensions: dim } = sys;
27
-
28
26
  // ── Layout constants ──────────────────────────────────────────────────────────
29
27
  const TRACK_W = 56;
30
28
  const TRACK_H = 32;
31
29
  const THUMB_SIZE = 24;
32
- const TRACK_PADDING = dim.spacing.padding.sysPadding4;
33
-
34
- // Thumb translateX: off=left edge, on=right edge
35
- const THUMB_OFF = TRACK_PADDING; // 4
36
- const THUMB_ON = TRACK_W - TRACK_PADDING - THUMB_SIZE; // 56 - 4 - 24 = 28
37
30
 
38
31
  // ── Component ─────────────────────────────────────────────────────────────────
39
32
 
@@ -45,6 +38,14 @@ export function Switch({
45
38
  accessibilityLabel,
46
39
  style,
47
40
  }: SwitchProps) {
41
+ const { colorRoles: cr, dimensions: dim } = useTheme();
42
+
43
+ const trackPadding = useMemo(() => dim.spacing.padding.sysPadding4, [dim]);
44
+
45
+ // Thumb translateX: off=left edge, on=right edge
46
+ const thumbOff = trackPadding; // 4
47
+ const thumbOn = TRACK_W - trackPadding - THUMB_SIZE; // 56 - 4 - 24 = 28
48
+
48
49
  // Controlled vs uncontrolled
49
50
  const isControlled = valueProp !== undefined;
50
51
  const [internalValue, setInternalValue] = useState(defaultValue);
@@ -54,14 +55,14 @@ export function Switch({
54
55
 
55
56
  // ── Animation ──────────────────────────────────────────────────────────────
56
57
  const thumbAnim = useRef(
57
- new Animated.Value(toggled ? THUMB_ON : THUMB_OFF),
58
+ new Animated.Value(toggled ? thumbOn : thumbOff),
58
59
  ).current;
59
60
  const bgAnim = useRef(new Animated.Value(toggled ? 1 : 0)).current;
60
61
 
61
62
  useEffect(() => {
62
63
  Animated.parallel([
63
64
  Animated.timing(thumbAnim, {
64
- toValue: toggled ? THUMB_ON : THUMB_OFF,
65
+ toValue: toggled ? thumbOn : thumbOff,
65
66
  duration: 150,
66
67
  useNativeDriver: true,
67
68
  }),
@@ -137,6 +138,7 @@ export function Switch({
137
138
  style={[
138
139
  styles.thumb,
139
140
  {
141
+ top: trackPadding,
140
142
  backgroundColor: thumbBg,
141
143
  transform: [{ translateX: thumbAnim }],
142
144
  },
@@ -167,7 +169,6 @@ const styles = StyleSheet.create({
167
169
  },
168
170
  thumb: {
169
171
  position: 'absolute',
170
- top: TRACK_PADDING,
171
172
  left: 0, // translateX drives actual X position
172
173
  width: THUMB_SIZE,
173
174
  height: THUMB_SIZE,
@@ -2,7 +2,7 @@ import React from 'react';
2
2
 
3
3
  import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
4
4
 
5
- import { sys } from '../../tokens';
5
+ import { useTheme } from '../../theme';
6
6
 
7
7
  export type TooltipVariant = 'filled' | 'elevated';
8
8
  /**
@@ -24,8 +24,6 @@ export interface TooltipProps {
24
24
  style?: StyleProp<ViewStyle>;
25
25
  }
26
26
 
27
- const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
28
-
29
27
  // ── Arrow dimensions ─────────────────────────────────────────────────────────
30
28
  const ARROW_BASE = 16; // px — wide axis
31
29
  const ARROW_HEIGHT = 6; // px — narrow axis
@@ -38,6 +36,8 @@ export function Tooltip({
38
36
  direction = 'none',
39
37
  style,
40
38
  }: TooltipProps) {
39
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = useTheme();
40
+
41
41
  const isFilled = variant === 'filled';
42
42
 
43
43
  const bg = isFilled
package/src/index.ts CHANGED
@@ -131,6 +131,15 @@ export type {
131
131
  ActionSheetAction,
132
132
  } from './components/ActionSheet';
133
133
 
134
+ export { ChatBubble } from './components/ChatBubble';
135
+ export type {
136
+ ChatBubbleProps,
137
+ ChatBubbleVariant,
138
+ } from './components/ChatBubble';
139
+
140
+ export { ChatInput } from './components/ChatInput';
141
+ export type { ChatInputProps, ChatAttachment } from './components/ChatInput';
142
+
134
143
  export { BodyText } from './components/BodyText';
135
144
  export type { BodyTextProps, BodyVariant } from './components/BodyText';
136
145
 
@@ -140,6 +149,10 @@ export type { HeaderTextProps, HeaderVariant } from './components/HeaderText';
140
149
  // Re-export tokens for consumers who need direct access
141
150
  export { sys } from './tokens';
142
151
 
152
+ // Theme system — override the default GuidanceNow theme per-app or per-subtree
153
+ export { ThemeProvider, useTheme, defaultTheme } from './theme';
154
+ export type { ThemeProviderProps, Theme, ThemeOverride } from './theme';
155
+
143
156
  // Icons
144
157
  export {
145
158
  UserRoundIcon,
@@ -0,0 +1,11 @@
1
+ import { createContext } from 'react';
2
+
3
+ import { sys } from '../tokens';
4
+ import type { Theme } from './types';
5
+
6
+ /**
7
+ * Provides the active resolved theme to all components.
8
+ * Default value is the GuidanceNow theme so components render correctly
9
+ * even when no ThemeProvider is present in the tree.
10
+ */
11
+ export const ThemeContext = createContext<Theme>(sys as unknown as Theme);
@@ -0,0 +1,40 @@
1
+ import React, { useMemo } from 'react';
2
+
3
+ import { ThemeContext } from './ThemeContext';
4
+ import { mergeTheme } from './mergeTheme';
5
+ import type { ThemeOverride } from './types';
6
+
7
+ export interface ThemeProviderProps {
8
+ /**
9
+ * Optional partial theme override. Keys present here replace the
10
+ * corresponding GuidanceNow default values; all other tokens keep their
11
+ * defaults. Pass `undefined` (or omit this prop) to use the GuidanceNow
12
+ * theme unchanged.
13
+ */
14
+ theme?: ThemeOverride;
15
+ children: React.ReactNode;
16
+ }
17
+
18
+ /**
19
+ * Wraps your application (or a subtree) and provides a resolved theme to all
20
+ * `@compsych/mobile-ui` components beneath it.
21
+ *
22
+ * @example — default GuidanceNow theme
23
+ * <ThemeProvider>
24
+ * <App />
25
+ * </ThemeProvider>
26
+ *
27
+ * @example — custom brand override
28
+ * <ThemeProvider theme={{ colorRoles: { accent: { primary: { sysPrimary: '#c00000' } } } }}>
29
+ * <App />
30
+ * </ThemeProvider>
31
+ */
32
+ export function ThemeProvider({ theme, children }: ThemeProviderProps) {
33
+ const resolvedTheme = useMemo(() => mergeTheme(theme), [theme]);
34
+
35
+ return (
36
+ <ThemeContext.Provider value={resolvedTheme}>
37
+ {children}
38
+ </ThemeContext.Provider>
39
+ );
40
+ }