@klyftig/mobile-ui 1.3.13 → 1.5.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.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@klyftig/mobile-ui",
3
- "version": "1.3.13",
3
+ "version": "1.5.0",
4
4
  "scripts": {
5
- "pack-install": "npm pack && mv klyftig-ui-*.tgz /tmp/klyftig-ui-local.tgz && cd ../../mobile && npm install /tmp/klyftig-ui-local.tgz"
5
+ "sync": "bash scripts/sync-to-kaelo.sh",
6
+ "pack-install": "npm pack && mv klyftig-mobile-ui-*.tgz /tmp/klyftig-mobile-ui-local.tgz && cd ../kaelo/mobile && npm install /tmp/klyftig-mobile-ui-local.tgz"
6
7
  },
7
8
  "description": "Klyftig shared UI components for React Native / Expo",
8
9
  "main": "src/index.ts",
@@ -23,6 +24,7 @@
23
24
  "react-native": ">=0.83.0",
24
25
  "expo-glass-effect": "*",
25
26
  "expo-linear-gradient": "*",
27
+ "expo-blur": "*",
26
28
  "@expo/vector-icons": "*",
27
29
  "react-native-svg": "*",
28
30
  "react-native-safe-area-context": "*"
@@ -35,7 +35,9 @@ export const AdaptiveGlassView: React.FC<AdaptiveGlassViewProps> = ({
35
35
  // dark shadow or tab-bar region), which makes the control look "darkened"
36
36
  // until the user scrolls. A subtle translucent white keeps the appearance
37
37
  // consistent while still reading as glass.
38
- const baseStyle: ViewStyle = { backgroundColor: 'rgba(255,255,255,0.55)' };
38
+ // Warm-tinted to match the app ground — a cool white base reads as a grey
39
+ // patch when it sits over the warm off-white background.
40
+ const baseStyle: ViewStyle = { backgroundColor: 'rgba(250,249,246,0.55)' };
39
41
  return (
40
42
  <GlassView
41
43
  glassEffectStyle={variant}
@@ -79,7 +79,7 @@ const styles = StyleSheet.create({
79
79
  padding: 24,
80
80
  width: '85%',
81
81
  maxWidth: 360,
82
- shadowColor: '#000',
82
+ shadowColor: '#3A3226',
83
83
  shadowOffset: { width: 0, height: 4 },
84
84
  shadowOpacity: 0.15,
85
85
  shadowRadius: 12,
@@ -55,7 +55,7 @@ const styles = StyleSheet.create({
55
55
  flex: 1,
56
56
  justifyContent: 'center',
57
57
  alignItems: 'center',
58
- backgroundColor: '#F5F4EF',
58
+ backgroundColor: colors.background.primary,
59
59
  paddingHorizontal: 32,
60
60
  },
61
61
  emoji: {
@@ -198,7 +198,7 @@ const styles = StyleSheet.create({
198
198
  fabFallback: {
199
199
  backgroundColor: colors.glass.fabFallback,
200
200
  elevation: 8,
201
- shadowColor: '#000',
201
+ shadowColor: '#3A3226',
202
202
  shadowOffset: { width: 0, height: 4 },
203
203
  shadowOpacity: 0.15,
204
204
  shadowRadius: 12,
@@ -239,7 +239,7 @@ const styles = StyleSheet.create({
239
239
  borderRadius: SUB_BUTTON_SIZE / 2,
240
240
  justifyContent: 'center',
241
241
  alignItems: 'center',
242
- shadowColor: '#000',
242
+ shadowColor: '#3A3226',
243
243
  shadowOffset: { width: 0, height: 2 },
244
244
  shadowOpacity: 0.25,
245
245
  shadowRadius: 4,
@@ -1,6 +1,9 @@
1
1
  import React, { useRef } from 'react';
2
2
  import { TouchableOpacity, Text, View, StyleSheet, ActivityIndicator, Animated } from 'react-native';
3
3
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { colors, shadows } from '../theme/colors';
5
+ import { textStyles } from '../theme/typography';
6
+ import { theme } from '../theme/theme';
4
7
 
5
8
  interface GlassButtonProps {
6
9
  title: string;
@@ -156,10 +159,10 @@ export const GlassButton: React.FC<GlassButtonProps> = ({
156
159
  ]}
157
160
  >
158
161
  {loading ? (
159
- <ActivityIndicator color="#1A1A1A" size={isCompact ? 'small' : 'large'} />
162
+ <ActivityIndicator color={colors.text.primary} size={isCompact ? 'small' : 'large'} />
160
163
  ) : (
161
164
  <>
162
- {icon && <Icon name={icon} size={isCompact ? 16 : 20} color="#1A1A1A" style={styles.buttonIcon} />}
165
+ {icon && <Icon name={icon} size={isCompact ? 16 : 20} color={colors.text.primary} style={styles.buttonIcon} />}
163
166
  <Text style={[styles.secondaryButtonText, isCompact && styles.secondaryButtonTextCompact]}>{title}</Text>
164
167
  </>
165
168
  )}
@@ -176,27 +179,22 @@ const styles = StyleSheet.create({
176
179
  flexDirection: 'row',
177
180
  alignItems: 'center',
178
181
  justifyContent: 'center',
179
- backgroundColor: '#1A1A1A',
182
+ backgroundColor: colors.accent.brand,
180
183
  borderWidth: 1,
181
184
  borderColor: 'rgba(255,255,255,0.15)',
182
- shadowColor: '#000',
183
- shadowOffset: { width: 0, height: 6 },
184
- shadowOpacity: 0.2,
185
- shadowRadius: 16,
186
- elevation: 6,
185
+ ...shadows.lg,
187
186
  },
188
187
  primaryButtonCompact: {
189
188
  height: 40,
190
- borderRadius: 10,
189
+ borderRadius: theme.borderRadius.control,
191
190
  paddingHorizontal: 20,
192
191
  },
193
192
  primaryButtonDisabled: {
194
193
  opacity: 0.5,
195
194
  },
196
195
  primaryButtonText: {
197
- fontSize: 16,
198
- fontWeight: '700',
199
- letterSpacing: 0.5,
196
+ ...textStyles.subtitle,
197
+ letterSpacing: 0.2,
200
198
  color: '#FFFFFF',
201
199
  },
202
200
  primaryButtonTextCompact: {
@@ -205,49 +203,41 @@ const styles = StyleSheet.create({
205
203
  },
206
204
  dangerButton: {
207
205
  height: 56,
208
- borderRadius: 16,
206
+ borderRadius: theme.borderRadius.card,
209
207
  flexDirection: 'row',
210
208
  alignItems: 'center',
211
209
  justifyContent: 'center',
212
- backgroundColor: '#F44336',
213
- shadowColor: '#000',
214
- shadowOffset: { width: 0, height: 2 },
215
- shadowOpacity: 0.15,
216
- shadowRadius: 6,
217
- elevation: 4,
210
+ backgroundColor: colors.accent.error,
211
+ ...shadows.md,
218
212
  },
219
213
  dangerButtonText: {
220
- fontSize: 16,
221
- fontWeight: '700',
222
- color: '#fff',
223
- letterSpacing: 0.5,
214
+ ...textStyles.subtitle,
215
+ letterSpacing: 0.2,
216
+ color: '#FFFFFF',
224
217
  },
218
+ // Tinted with the brand rather than left white-on-white. A white fill with an
219
+ // 8%-alpha border over the warm ground read as a DISABLED control — there was
220
+ // no visual difference between "secondary action" and "you cannot press this".
225
221
  secondaryButton: {
226
222
  height: 56,
227
- borderRadius: 16,
223
+ borderRadius: theme.borderRadius.card,
228
224
  flexDirection: 'row',
229
225
  alignItems: 'center',
230
226
  justifyContent: 'center',
231
- backgroundColor: '#FFFFFF',
227
+ backgroundColor: colors.accent.brandSoft,
232
228
  borderWidth: 1,
233
- borderColor: 'rgba(0,0,0,0.08)',
229
+ borderColor: 'rgba(31,94,74,0.14)',
234
230
  overflow: 'hidden',
235
- shadowColor: '#000',
236
- shadowOffset: { width: 0, height: 2 },
237
- shadowOpacity: 0.06,
238
- shadowRadius: 8,
239
- elevation: 0,
240
231
  },
241
232
  secondaryButtonCompact: {
242
233
  height: 40,
243
- borderRadius: 10,
234
+ borderRadius: theme.borderRadius.control,
244
235
  paddingHorizontal: 20,
245
236
  },
246
237
  secondaryButtonText: {
247
- fontSize: 16,
248
- fontWeight: '600',
249
- letterSpacing: 0.5,
250
- color: '#1A1A1A',
238
+ ...textStyles.subtitle,
239
+ letterSpacing: 0.2,
240
+ color: colors.accent.brand,
251
241
  },
252
242
  secondaryButtonTextCompact: {
253
243
  fontSize: 14,
@@ -269,10 +259,12 @@ const styles = StyleSheet.create({
269
259
  textButtonNotFull: {
270
260
  alignSelf: 'center',
271
261
  },
262
+ // Brand-colored so a bare text action reads as tappable; the old neutral grey
263
+ // was indistinguishable from disabled helper text.
272
264
  textButtonText: {
273
265
  fontSize: 15,
274
266
  fontWeight: '600',
275
- color: '#6B6B6B',
267
+ color: colors.accent.brand,
276
268
  },
277
269
  textButtonTextCompact: {
278
270
  fontSize: 14,
@@ -1,9 +1,20 @@
1
1
  import React from 'react';
2
- import { View, StyleSheet, ViewStyle } from 'react-native';
2
+ import { View, StyleSheet, ViewStyle, ViewProps } from 'react-native';
3
3
  import { AdaptiveGlassView } from './AdaptiveGlassView';
4
4
  import { useTheme } from '../hooks/useTheme';
5
+ import { theme } from '../theme/theme';
6
+ import { shadows } from '../theme/colors';
5
7
 
6
- interface GlassCardProps {
8
+ /**
9
+ * Extends ViewProps so callers can pass accessibility props (`accessible`,
10
+ * `accessibilityLabel`, `accessibilityRole`, `testID`, …) straight through to
11
+ * the underlying view. A card that composes several Texts into one figure
12
+ * usually wants to expose itself to screen readers as a single element.
13
+ *
14
+ * Only the non-glass branch forwards them — the native glass view takes a
15
+ * fixed prop set, so `useGlass` cards stay presentational.
16
+ */
17
+ interface GlassCardProps extends Omit<ViewProps, 'style'> {
7
18
  children: React.ReactNode;
8
19
  style?: ViewStyle;
9
20
  intensity?: number;
@@ -17,6 +28,9 @@ export const GlassCard: React.FC<GlassCardProps> = ({
17
28
  style,
18
29
  padding = 16,
19
30
  useGlass = false,
31
+ intensity: _intensity,
32
+ tint: _tint,
33
+ ...viewProps
20
34
  }) => {
21
35
  const { colors } = useTheme();
22
36
 
@@ -34,6 +48,7 @@ export const GlassCard: React.FC<GlassCardProps> = ({
34
48
 
35
49
  return (
36
50
  <View
51
+ {...viewProps}
37
52
  style={[
38
53
  styles.card,
39
54
  { backgroundColor: colors.background.card, borderColor: colors.border.light },
@@ -48,12 +63,8 @@ export const GlassCard: React.FC<GlassCardProps> = ({
48
63
 
49
64
  const styles = StyleSheet.create({
50
65
  card: {
51
- borderRadius: 20,
66
+ borderRadius: theme.borderRadius.card,
52
67
  borderWidth: 1,
53
- shadowColor: '#000',
54
- shadowOffset: { width: 0, height: 2 },
55
- shadowOpacity: 0.06,
56
- shadowRadius: 8,
57
- elevation: 3,
68
+ ...shadows.md,
58
69
  },
59
70
  });
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { View, StyleSheet, ViewStyle } from 'react-native';
3
+ import { colors } from '../theme/colors';
3
4
 
4
5
  interface GlassContainerProps {
5
6
  children: React.ReactNode;
@@ -21,6 +22,9 @@ export const GlassContainer: React.FC<GlassContainerProps> = ({
21
22
  const styles = StyleSheet.create({
22
23
  container: {
23
24
  flex: 1,
24
- backgroundColor: '#FFFFFF',
25
+ // The app ground. It must stay DARKER than background.card — cards are
26
+ // separated from the page by contrast rather than by heavy shadow, so a
27
+ // white ground makes every white card's edge disappear.
28
+ backgroundColor: colors.background.primary,
25
29
  },
26
30
  });
@@ -0,0 +1,114 @@
1
+ import React, { useCallback } from 'react';
2
+ import { View, StyleSheet, Platform, type ViewStyle, type StyleProp, type LayoutChangeEvent } from 'react-native';
3
+ import { BlurView } from 'expo-blur';
4
+ import { LinearGradient } from 'expo-linear-gradient';
5
+ import { GlassView } from 'expo-glass-effect';
6
+ import { GLASS_AVAILABLE } from '../utils/glass';
7
+ import { colors } from '../theme/colors';
8
+
9
+ interface GlassFooterProps {
10
+ children: React.ReactNode;
11
+ /** Safe-area inset to pad below the content (usually insets.bottom). */
12
+ bottomInset?: number;
13
+ /**
14
+ * Reports the bar's metrics on layout.
15
+ *
16
+ * `obstruction` is what a scroll view should reserve: the whole bar plus its
17
+ * fade. Content is not readable anywhere inside that band — the blur, the
18
+ * legibility scrim and the gradient all wash it out, not just the button —
19
+ * so reserving only as far as the button's top edge still leaves the last
20
+ * element greyed out and looking cut off.
21
+ */
22
+ onMetrics?: (metrics: { height: number; obstruction: number }) => void;
23
+ style?: StyleProp<ViewStyle>;
24
+ }
25
+
26
+ /** Height of the gradient that softens the top edge of the bar. */
27
+ export const GLASS_FOOTER_FADE = 20;
28
+
29
+ /** Space above the controls, inside the bar. */
30
+ const CONTENT_PADDING_TOP = 10;
31
+
32
+ /**
33
+ * A translucent bottom action bar that scroll content passes beneath.
34
+ *
35
+ * The point is the affordance: an opaque bar terminates the page, so a screen
36
+ * that continues below it looks finished when it isn't. Letting content stay
37
+ * faintly visible through the bar tells the user there is more to scroll.
38
+ *
39
+ * Three rendering paths, because no single one covers the fleet:
40
+ * - iOS 26+ → the native Liquid Glass material.
41
+ * - elsewhere → expo-blur. Android needs the experimental blur method;
42
+ * without it BlurView renders as a flat translucent fill.
43
+ * - either way → a tinted scrim over the blur keeps button labels legible
44
+ * no matter what scrolls underneath, and a gradient softens
45
+ * the top edge so the bar doesn't read as a hard seam.
46
+ */
47
+ export const GlassFooter: React.FC<GlassFooterProps> = ({
48
+ children,
49
+ bottomInset = 0,
50
+ onMetrics,
51
+ style,
52
+ }) => {
53
+ const handleLayout = useCallback(
54
+ (e: LayoutChangeEvent) => {
55
+ const height = e.nativeEvent.layout.height;
56
+ onMetrics?.({ height, obstruction: height + GLASS_FOOTER_FADE });
57
+ },
58
+ [onMetrics]
59
+ );
60
+
61
+ return (
62
+ <View style={[styles.wrap, style]} onLayout={handleLayout} pointerEvents="box-none">
63
+ <LinearGradient
64
+ pointerEvents="none"
65
+ colors={[`${colors.background.primary}00`, colors.background.primary]}
66
+ style={styles.fade}
67
+ />
68
+
69
+ {GLASS_AVAILABLE ? (
70
+ <GlassView glassEffectStyle="regular" style={StyleSheet.absoluteFill} />
71
+ ) : (
72
+ <BlurView
73
+ intensity={Platform.OS === 'android' ? 24 : 40}
74
+ tint="light"
75
+ // Android's default blur is a no-op; this opts into the real one.
76
+ experimentalBlurMethod={Platform.OS === 'android' ? 'dimezisBlurView' : undefined}
77
+ style={StyleSheet.absoluteFill}
78
+ />
79
+ )}
80
+
81
+ {/* Legibility scrim — the blur alone can't guarantee contrast against
82
+ arbitrary scrolling content. */}
83
+ <View pointerEvents="none" style={styles.scrim} />
84
+
85
+ <View style={[styles.content, { paddingBottom: bottomInset }]}>{children}</View>
86
+ </View>
87
+ );
88
+ };
89
+
90
+ const styles = StyleSheet.create({
91
+ wrap: {
92
+ position: 'absolute',
93
+ left: 0,
94
+ right: 0,
95
+ bottom: 0,
96
+ overflow: 'visible',
97
+ },
98
+ fade: {
99
+ position: 'absolute',
100
+ left: 0,
101
+ right: 0,
102
+ top: -GLASS_FOOTER_FADE,
103
+ height: GLASS_FOOTER_FADE,
104
+ },
105
+ scrim: {
106
+ ...StyleSheet.absoluteFillObject,
107
+ backgroundColor: 'rgba(250,249,246,0.72)',
108
+ },
109
+ content: {
110
+ paddingHorizontal: 20,
111
+ paddingTop: CONTENT_PADDING_TOP,
112
+ gap: 12,
113
+ },
114
+ });
@@ -1,8 +1,9 @@
1
1
  import React, { useState, useCallback, memo } from 'react';
2
2
  import { View, TextInput as RNTextInput, Text, TouchableOpacity, StyleSheet } from 'react-native';
3
- import type { NativeSyntheticEvent, TextInputContentSizeChangeEventData } from 'react-native';
3
+ import type { NativeSyntheticEvent, StyleProp, TextInputContentSizeChangeEventData, ViewStyle } from 'react-native';
4
4
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
5
5
  import { useTheme } from '../hooks/useTheme';
6
+ import { colors } from '../theme/colors';
6
7
 
7
8
  interface GlassInputProps {
8
9
  label?: string;
@@ -34,6 +35,10 @@ interface GlassInputProps {
34
35
  onFocus?: () => void;
35
36
  onBlur?: () => void;
36
37
  selectTextOnFocus?: boolean;
38
+ // Overrides the outer container (label + field). Its default `marginBottom: 16`
39
+ // suits stacked forms; screens with their own section rhythm pass
40
+ // `{ marginBottom: 0 }` so spacing is owned by the layout, not the input.
41
+ containerStyle?: StyleProp<ViewStyle>;
37
42
  }
38
43
 
39
44
  // When a single-line input is not focused, anchor the selection to the start so
@@ -72,6 +77,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
72
77
  multiline = false,
73
78
  numberOfLines,
74
79
  autoGrow = false,
80
+ containerStyle,
75
81
  }) => {
76
82
  const { colors } = useTheme();
77
83
  const [isSecure, setIsSecure] = useState(secureTextEntry);
@@ -105,7 +111,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
105
111
  }, []);
106
112
 
107
113
  return (
108
- <View style={styles.container}>
114
+ <View style={[styles.container, containerStyle]}>
109
115
  {label ? (
110
116
  <Text style={[styles.label, { color: colors.text.primary }]}>{label}</Text>
111
117
  ) : null}
@@ -124,13 +130,13 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
124
130
  ]}
125
131
  >
126
132
  {icon && (
127
- <Icon name={icon} size={20} color="#9B9B9B" style={styles.leftIcon} />
133
+ <Icon name={icon} size={20} color={colors.text.tertiary} style={styles.leftIcon} />
128
134
  )}
129
135
  <RNTextInput
130
136
  value={value}
131
137
  onChangeText={onChangeText}
132
138
  placeholder={placeholder}
133
- placeholderTextColor="#C5C5C5"
139
+ placeholderTextColor={colors.text.disabled}
134
140
  secureTextEntry={isSecure}
135
141
  keyboardType={keyboardType}
136
142
  autoCapitalize={autoCapitalize}
@@ -172,7 +178,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
172
178
  <Icon
173
179
  name={isSecure ? 'eye-off' : 'eye'}
174
180
  size={20}
175
- color="#9B9B9B"
181
+ color={colors.text.tertiary}
176
182
  />
177
183
  </TouchableOpacity>
178
184
  )}
@@ -201,8 +207,8 @@ const styles = StyleSheet.create({
201
207
  borderColor: 'rgba(0,0,0,0.08)',
202
208
  },
203
209
  inputContainerFocused: {
204
- borderColor: '#1A1A1A',
205
- backgroundColor: '#FFFFFF',
210
+ borderColor: colors.accent.brand,
211
+ backgroundColor: colors.background.card,
206
212
  },
207
213
  inputContainerDisabled: {
208
214
  opacity: 0.6,
@@ -243,7 +249,7 @@ const styles = StyleSheet.create({
243
249
  fontSize: 16,
244
250
  height: '100%',
245
251
  paddingVertical: 0,
246
- color: '#1A1A1A',
252
+ color: colors.text.primary,
247
253
  },
248
254
  inputWithLeftIcon: {
249
255
  marginLeft: 0,
@@ -255,7 +261,7 @@ const styles = StyleSheet.create({
255
261
  fontSize: 16,
256
262
  fontWeight: '600',
257
263
  marginLeft: 8,
258
- color: '#6B6B6B',
264
+ color: colors.text.secondary,
259
265
  },
260
266
  eyeIcon: {
261
267
  padding: 8,
@@ -122,7 +122,7 @@ const styles = StyleSheet.create({
122
122
  borderRadius: 16,
123
123
  borderWidth: 1,
124
124
  marginBottom: 12,
125
- shadowColor: '#000',
125
+ shadowColor: '#3A3226',
126
126
  shadowOffset: { width: 0, height: 2 },
127
127
  shadowOpacity: 0.06,
128
128
  shadowRadius: 8,
@@ -4,6 +4,7 @@ import { LinearGradient } from 'expo-linear-gradient';
4
4
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
5
5
  import { AdaptiveGlassView } from './AdaptiveGlassView';
6
6
  import { useTheme } from '../hooks/useTheme';
7
+ import { colors } from '../theme/colors';
7
8
 
8
9
  interface GlassStatsCardProps {
9
10
  icon: string;
@@ -46,7 +47,7 @@ export const GlassStatsCard: React.FC<GlassStatsCardProps> = ({
46
47
  <AdaptiveGlassView
47
48
  variant="regular"
48
49
  style={styles.gradient}
49
- fallbackStyle={{ backgroundColor: '#FFFFFF' }}
50
+ fallbackStyle={{ backgroundColor: colors.background.card }}
50
51
  >
51
52
  {cardChildren}
52
53
  </AdaptiveGlassView>
@@ -125,10 +126,10 @@ const styles = StyleSheet.create({
125
126
  card: {
126
127
  borderRadius: 20,
127
128
  overflow: 'hidden',
128
- backgroundColor: '#FFFFFF',
129
+ backgroundColor: colors.background.card,
129
130
  borderWidth: 1,
130
131
  borderColor: 'rgba(0,0,0,0.06)',
131
- shadowColor: '#000',
132
+ shadowColor: '#3A3226',
132
133
  shadowOffset: { width: 0, height: 2 },
133
134
  shadowOpacity: 0.06,
134
135
  shadowRadius: 8,
@@ -171,7 +172,7 @@ const styles = StyleSheet.create({
171
172
  borderRadius: 16,
172
173
  overflow: 'hidden',
173
174
  borderWidth: 1,
174
- backgroundColor: '#FFFFFF',
175
+ backgroundColor: colors.background.card,
175
176
  },
176
177
  macroGradient: {
177
178
  padding: 16,
@@ -149,7 +149,7 @@ const styles = StyleSheet.create({
149
149
  card: {
150
150
  borderRadius: 20,
151
151
  borderWidth: 1,
152
- shadowColor: '#000',
152
+ shadowColor: '#3A3226',
153
153
  shadowOffset: { width: 0, height: 2 },
154
154
  shadowOpacity: 0.06,
155
155
  shadowRadius: 8,
@@ -13,6 +13,7 @@ import {
13
13
  import { LinearGradient } from 'expo-linear-gradient';
14
14
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
15
15
  import { useTheme } from '../hooks/useTheme';
16
+ import { colors } from '../theme/colors';
16
17
 
17
18
  export interface RecipeRowProps {
18
19
  title: string;
@@ -180,7 +181,7 @@ const styles = StyleSheet.create({
180
181
  borderRadius: 18,
181
182
  borderWidth: 1,
182
183
  overflow: 'hidden',
183
- shadowColor: '#000',
184
+ shadowColor: '#3A3226',
184
185
  shadowOffset: { width: 0, height: 2 },
185
186
  shadowOpacity: 0.06,
186
187
  shadowRadius: 8,
@@ -197,7 +198,7 @@ const styles = StyleSheet.create({
197
198
  height: 60,
198
199
  borderRadius: 14,
199
200
  overflow: 'hidden',
200
- backgroundColor: '#F0EFEA',
201
+ backgroundColor: colors.background.secondary,
201
202
  },
202
203
  thumbImage: {
203
204
  width: '100%',
@@ -129,7 +129,7 @@ const styles = StyleSheet.create({
129
129
  borderRadius: 0,
130
130
  },
131
131
  segmentActiveShadow: {
132
- shadowColor: '#000',
132
+ shadowColor: '#3A3226',
133
133
  shadowOffset: { width: 0, height: 1 },
134
134
  shadowOpacity: 0.06,
135
135
  shadowRadius: 3,
@@ -8,6 +8,7 @@ import {
8
8
  NativeScrollEvent,
9
9
  } from 'react-native';
10
10
  import { useTheme } from '../hooks/useTheme';
11
+ import { colors } from '../theme/colors';
11
12
 
12
13
  export interface WheelPickerProps {
13
14
  values: number[];
@@ -21,7 +22,11 @@ export interface WheelPickerProps {
21
22
  }
22
23
 
23
24
  const DEFAULT_ITEM_HEIGHT = 48;
24
- const DEFAULT_VISIBLE_COUNT = 5;
25
+ // 3 rows — the selected value plus one neighbour either side. Five rows meant
26
+ // 240dp for a single control, which on the onboarding Goal step pushed the
27
+ // bottom row under the pinned footer and forced the whole step to scroll.
28
+ // One neighbour each way is enough to read as a wheel rather than a text field.
29
+ const DEFAULT_VISIBLE_COUNT = 3;
25
30
  // Render this many rows above and below the active index. The remaining rows
26
31
  // are virtual — the scroll container still spans the full height so snapping
27
32
  // and momentum behave normally, but we don't mount 600 <Text> nodes on first
@@ -131,7 +136,7 @@ export function WheelPicker({
131
136
  pointerEvents="none"
132
137
  style={[
133
138
  styles.selectionOverlay,
134
- { top: padding, height: itemHeight, backgroundColor: 'rgba(0,0,0,0.06)' },
139
+ { top: padding, height: itemHeight, backgroundColor: colors.background.secondary },
135
140
  ]}
136
141
  />
137
142
  <ScrollView
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
2
2
  import { View, StyleSheet, Animated } from 'react-native';
3
3
  import Svg, { Circle, G } from 'react-native-svg';
4
4
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
5
+ import { colors } from '../../theme/colors';
5
6
 
6
7
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
7
8
 
@@ -88,7 +89,7 @@ export const CArcProgress: React.FC<CArcProgressProps> = ({
88
89
  </Svg>
89
90
  {/* Center icon */}
90
91
  <View style={styles.centerIcon}>
91
- <Icon name="fire" size={size * 0.22} color="#FF6B35" />
92
+ <Icon name="fire" size={size * 0.22} color={colors.accent.warning} />
92
93
  </View>
93
94
  </View>
94
95
  );
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
2
2
  import { View, Text, StyleSheet, Animated } from 'react-native';
3
3
  import Svg, { Circle, G } from 'react-native-svg';
4
4
  import { useTheme } from '../../hooks/useTheme';
5
+ import { textStyles } from '../../theme/typography';
5
6
 
6
7
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
7
8
 
@@ -100,7 +101,7 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
100
101
  cx={size / 2}
101
102
  cy={size / 2}
102
103
  r={radius}
103
- stroke="rgba(0,0,0,0.08)"
104
+ stroke={colors.background.track}
104
105
  strokeWidth={strokeWidth}
105
106
  fill="transparent"
106
107
  />
@@ -163,14 +164,23 @@ const createStyles = (colors: any) => StyleSheet.create({
163
164
  color: colors.text.secondary,
164
165
  marginBottom: 4,
165
166
  },
167
+ // The number at the center of a ring is the hero of whatever card it sits
168
+ // in, so it takes the display treatment: tabular figures (the value ticks as
169
+ // the ring animates and would otherwise jitter) and negative tracking, which
170
+ // Plus Jakarta Sans needs above ~24px to stop looking inflated.
171
+ // No lineHeight: `valueSize` overrides fontSize at the call site (up to 36+),
172
+ // and a fixed lineHeight from the ramp would clip the taller digits.
166
173
  value: {
167
174
  fontSize: 24,
168
- fontWeight: '800',
175
+ fontWeight: textStyles.metric.fontWeight,
176
+ letterSpacing: textStyles.metric.letterSpacing,
177
+ fontVariant: textStyles.metric.fontVariant,
169
178
  color: colors.text.primary,
170
179
  },
171
180
  unit: {
172
181
  fontSize: 14,
173
182
  fontWeight: '600',
183
+ letterSpacing: 0,
174
184
  color: colors.text.secondary,
175
185
  },
176
186
  target: {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Theme
2
2
  export { colors, shadows } from './theme/colors';
3
- export { typography, fonts, fontFamily } from './theme/typography';
3
+ export { typography, textStyles, numeric, fonts, fontFamily } from './theme/typography';
4
+ export type { TextStyleName } from './theme/typography';
4
5
  export { spacing } from './theme/spacing';
5
6
  export { theme } from './theme/theme';
6
7
  export type { Theme } from './theme/theme';
@@ -21,6 +22,7 @@ export type { CircleIconButtonProps, CircleIconButtonVariant } from './component
21
22
  export { GlassCard } from './components/GlassCard';
22
23
  export { GlassContainer } from './components/GlassContainer';
23
24
  export { GlassHeaderBackground } from './components/GlassHeaderBackground';
25
+ export { GlassFooter, GLASS_FOOTER_FADE } from './components/GlassFooter';
24
26
  export { GlassInput } from './components/GlassInput';
25
27
  export { GlassListItem, GlassToggleItem } from './components/GlassList';
26
28
  export { GlassStatsCard, GlassMacroCard, GlassNutrientRow } from './components/GlassStats';
@@ -1,54 +1,95 @@
1
1
  /**
2
- * Cal AI-inspired clean, light-only color palette
3
- * Warm off-white backgrounds, pure white cards, subtle shadows
2
+ * Warm Editorial palette — light-only.
3
+ *
4
+ * Design intent: a warm off-white ground with pure-white cards, so surfaces
5
+ * separate by CONTRAST rather than by heavy shadow. One deep brand green
6
+ * carries every primary action and the hero calorie ring; the macro hues are
7
+ * deliberately desaturated so four rings can sit side by side without the
8
+ * dashboard reading as a color test card.
9
+ *
10
+ * Every key here predates this palette and is consumed across the app — this
11
+ * file changes VALUES only. Adding keys is fine; renaming or removing one is a
12
+ * breaking change for @klyftig/mobile-ui consumers.
13
+ *
14
+ * Not defined here: the Nutri-Score grade colors (A–E). Those are the official
15
+ * regulated Nutri-Score swatches and live in Kaelo's utils/healthScore.ts —
16
+ * they are not ours to restyle.
4
17
  */
5
18
 
6
19
  export const colors = {
7
20
  background: {
8
- primary: '#FFFFFF', // Clean white background
9
- card: '#FFFFFF',
10
- secondary: '#F5F5F5',
21
+ primary: '#FAF9F6', // Warm ground — the page behind every card
22
+ card: '#FFFFFF', // Cards sit ABOVE the ground, not flush with it
23
+ secondary: '#F2F1EC', // Sunken fills: pills, inert chips, inputs
24
+ track: '#E9E7E0', // Unfilled portion of rings and progress bars
11
25
  tabBar: '#FFFFFF',
12
- gradient: ['#FFFFFF', '#FFFFFF', '#FFFFFF'] as const, // backward compat
26
+ gradient: ['#FAF9F6', '#FAF9F6', '#FAF9F6'] as const, // backward compat
13
27
  },
14
28
  text: {
15
- primary: '#1A1A1A',
16
- secondary: '#6B6B6B',
17
- tertiary: '#9B9B9B',
18
- disabled: '#C5C5C5',
29
+ primary: '#14140F', // Warm near-black; pure #000 reads cold here
30
+ secondary: '#6E6B63',
31
+ tertiary: '#A3A099',
32
+ disabled: '#C9C6BE',
19
33
  },
20
34
  icon: {
21
- primary: '#1A1A1A',
22
- secondary: '#6B6B6B',
23
- tertiary: '#9B9B9B',
35
+ primary: '#14140F',
36
+ secondary: '#6E6B63',
37
+ tertiary: '#A3A099',
24
38
  },
25
39
  border: {
26
- light: 'rgba(0,0,0,0.06)',
27
- medium: 'rgba(0,0,0,0.10)',
28
- heavy: 'rgba(0,0,0,0.15)',
40
+ // Warm-tinted alpha. Neutral black borders over a warm ground read grey
41
+ // and slightly dirty.
42
+ light: 'rgba(20,20,15,0.06)',
43
+ medium: 'rgba(20,20,15,0.10)',
44
+ heavy: 'rgba(20,20,15,0.15)',
29
45
  },
30
46
  glass: { // backward compat aliases
31
47
  background: '#FFFFFF',
32
- backgroundLight: '#F7F7F4',
48
+ backgroundLight: '#F2F1EC',
33
49
  backgroundHeavy: '#FFFFFF',
34
50
  tabBarFallback: '#FFFFFF',
35
- fabFallback: '#1A1A1A',
36
- headerFallback: 'rgba(255, 255, 255, 0.95)',
51
+ // Neutral ink: the FAB is NAVIGATION. The camera button beside it is the
52
+ // primary action and is the one that carries the brand color.
53
+ fabFallback: '#14140F',
54
+ headerFallback: 'rgba(250, 249, 246, 0.95)',
37
55
  },
38
56
  accent: {
39
- blue: '#2196F3',
40
- brand: '#1A1A1A', // Black primary buttons (Cal AI style)
41
- brandLight: '#4A4A4A',
42
- success: '#4CAF50',
43
- warning: '#FF9800',
44
- error: '#F44336',
45
- green: '#4CAF50',
57
+ blue: '#4A7FB5',
58
+ brand: '#1F5E4A', // Deep green — primary buttons, selection, hero ring
59
+ brandSoft: '#E7F0EC', // Tinted fill for secondary / low-emphasis buttons
60
+ brandPressed: '#17493A',
61
+ brandLight: '#5C9179', // Lighter brand tone for chart gradient fills
62
+ success: '#2F7D5B',
63
+ warning: '#C4863A',
64
+ error: '#B4483C',
65
+ green: '#2F7D5B',
66
+ },
67
+ /**
68
+ * Categorical accents — for things that must be told APART rather than
69
+ * ranked: meal slots, nav destinations, feature identities.
70
+ *
71
+ * Deliberately a fixed, ordered set. Every one of these was previously an
72
+ * ad-hoc Tailwind default (violet-500, sky-500, teal-500, …) picked at the
73
+ * call site, which is why the app accumulated four unrelated palettes. Pick
74
+ * from here; do not introduce a new hue at a call site.
75
+ *
76
+ * These are desaturated to sit on the warm ground without shouting, and are
77
+ * distinguishable from each other and from the macro hues.
78
+ */
79
+ categorical: {
80
+ amber: '#C4863A',
81
+ blue: '#4A7FB5',
82
+ plum: '#6B4E71',
83
+ green: '#2F7D5B',
84
+ teal: '#3E7B78',
85
+ clay: '#B4674A',
46
86
  },
47
87
  macro: {
48
- protein: '#E91E63',
49
- carbs: '#2196F3',
50
- fat: '#FF9800',
51
- calories: '#4CAF50',
88
+ protein: '#C0567A',
89
+ carbs: '#4A7FB5',
90
+ fat: '#D08A3E',
91
+ fiber: '#8A7B52', // Muted olive — distinct from both fat and brand
92
+ calories: '#1F5E4A', // Hero metric carries the brand color
52
93
  },
53
94
  shadow: {
54
95
  text: 'transparent',
@@ -57,20 +98,47 @@ export const colors = {
57
98
  },
58
99
  };
59
100
 
101
+ /**
102
+ * Elevation scale. shadowColor is warm (#3A3226) rather than pure black —
103
+ * a neutral shadow over a warm ground casts a grey halo that makes white cards
104
+ * look dirty at the edges.
105
+ */
60
106
  export const shadows = {
107
+ sm: {
108
+ shadowColor: '#3A3226',
109
+ shadowOffset: { width: 0, height: 1 },
110
+ shadowOpacity: 0.05,
111
+ shadowRadius: 3,
112
+ elevation: 1,
113
+ },
114
+ md: {
115
+ shadowColor: '#3A3226',
116
+ shadowOffset: { width: 0, height: 3 },
117
+ shadowOpacity: 0.07,
118
+ shadowRadius: 10,
119
+ elevation: 3,
120
+ },
121
+ lg: {
122
+ shadowColor: '#3A3226',
123
+ shadowOffset: { width: 0, height: 8 },
124
+ shadowOpacity: 0.10,
125
+ shadowRadius: 24,
126
+ elevation: 8,
127
+ },
128
+ // Pre-existing names, now aliased onto the scale above.
61
129
  card: {
62
- shadowColor: '#000',
63
- shadowOffset: { width: 0, height: 2 },
64
- shadowOpacity: 0.06,
65
- shadowRadius: 8,
130
+ shadowColor: '#3A3226',
131
+ shadowOffset: { width: 0, height: 3 },
132
+ shadowOpacity: 0.07,
133
+ shadowRadius: 10,
66
134
  elevation: 3,
67
135
  },
68
136
  cardElevated: {
69
- shadowColor: '#000',
70
- shadowOffset: { width: 0, height: 4 },
71
- shadowOpacity: 0.08,
72
- shadowRadius: 12,
73
- elevation: 5,
137
+ shadowColor: '#3A3226',
138
+ shadowOffset: { width: 0, height: 8 },
139
+ shadowOpacity: 0.10,
140
+ shadowRadius: 24,
141
+ elevation: 8,
74
142
  },
75
143
  text: { color: 'transparent', offset: { width: 0, height: 0 }, radius: 0 },
76
144
  textLight: { color: 'transparent', offset: { width: 0, height: 0 }, radius: 0 },
@@ -3,18 +3,27 @@
3
3
  */
4
4
 
5
5
  import { colors, shadows } from './colors';
6
- import { typography } from './typography';
6
+ import { typography, textStyles, numeric } from './typography';
7
7
  import { spacing } from './spacing';
8
8
 
9
9
  export const theme = {
10
10
  colors,
11
11
  shadows,
12
12
  typography,
13
+ textStyles,
14
+ numeric,
13
15
  spacing,
14
16
 
15
- // Border radius
17
+ /**
18
+ * Border radius. `control` (12) / `card` (16) / `sheet` (20) are the three
19
+ * that should cover almost every surface — reach for a raw number only when
20
+ * none of them fits.
21
+ */
16
22
  borderRadius: {
17
23
  sm: 8,
24
+ control: 12,
25
+ card: 16,
26
+ sheet: 20,
18
27
  md: 12,
19
28
  lg: 16,
20
29
  xl: 20,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Typography system — Plus Jakarta Sans
3
3
  */
4
+ import type { TextStyle } from 'react-native';
4
5
 
5
6
  /** Font family names (must match the loaded .ttf file names) */
6
7
  export const fonts = {
@@ -10,15 +11,26 @@ export const fonts = {
10
11
  bold: 'PlusJakartaSans_700Bold',
11
12
  } as const;
12
13
 
13
- /** Maps a numeric fontWeight string to the correct fontFamily */
14
- export function fontFamily(weight?: string): string {
15
- switch (weight) {
14
+ /**
15
+ * Maps a React Native fontWeight to the matching Plus Jakarta Sans family.
16
+ *
17
+ * Plus Jakarta Sans is loaded as four separate static faces, so weight has to
18
+ * be resolved to a family name — RN cannot synthesize it from a numeric weight.
19
+ * Accepts the named aliases ('bold', 'semibold', …) as well as the numeric
20
+ * strings, because both forms appear in style objects across the app.
21
+ */
22
+ export function fontFamily(weight?: string | number): string {
23
+ switch (String(weight)) {
16
24
  case '700':
17
25
  case '800':
26
+ case '900':
27
+ case 'bold':
18
28
  return fonts.bold;
19
29
  case '600':
30
+ case 'semibold':
20
31
  return fonts.semiBold;
21
32
  case '500':
33
+ case 'medium':
22
34
  return fonts.medium;
23
35
  default:
24
36
  return fonts.regular;
@@ -54,3 +66,89 @@ export const typography = {
54
66
  relaxed: 1.75,
55
67
  },
56
68
  };
69
+
70
+ /**
71
+ * Tabular figures. Every number that ticks, counts, or sits in a column needs
72
+ * this — proportional digits change width as the value changes, so a live
73
+ * timer or an animated counter visibly jitters without it.
74
+ */
75
+ export const numeric: TextStyle = {
76
+ fontVariant: ['tabular-nums'],
77
+ };
78
+
79
+ /**
80
+ * The type ramp. Prefer these over ad-hoc fontSize/fontWeight pairs so the
81
+ * hierarchy stays consistent across screens.
82
+ *
83
+ * Negative tracking on the large sizes is deliberate: Plus Jakarta Sans is
84
+ * drawn for text sizes and spaces too loosely once it passes ~24px, which is
85
+ * what makes big numerals look inflated rather than composed.
86
+ *
87
+ * `display` and `metric` already carry tabular figures.
88
+ */
89
+ export type TextStyleName =
90
+ | 'display'
91
+ | 'metric'
92
+ | 'title'
93
+ | 'subtitle'
94
+ | 'body'
95
+ | 'bodySmall'
96
+ | 'label'
97
+ | 'caption';
98
+
99
+ export const textStyles: Record<TextStyleName, TextStyle> = {
100
+ /** Hero numerals only — the one number a screen is about. */
101
+ display: {
102
+ fontSize: 40,
103
+ fontWeight: '700',
104
+ lineHeight: 44,
105
+ letterSpacing: -1.0,
106
+ ...numeric,
107
+ },
108
+ /** The anchor value of a card. */
109
+ metric: {
110
+ fontSize: 28,
111
+ fontWeight: '700',
112
+ lineHeight: 32,
113
+ letterSpacing: -0.6,
114
+ ...numeric,
115
+ },
116
+ /** Screen and section titles. */
117
+ title: {
118
+ fontSize: 20,
119
+ fontWeight: '700',
120
+ lineHeight: 26,
121
+ letterSpacing: -0.3,
122
+ },
123
+ /** Card headings. */
124
+ subtitle: {
125
+ fontSize: 16,
126
+ fontWeight: '600',
127
+ lineHeight: 22,
128
+ letterSpacing: -0.2,
129
+ },
130
+ body: {
131
+ fontSize: 16,
132
+ fontWeight: '400',
133
+ lineHeight: 24,
134
+ },
135
+ bodySmall: {
136
+ fontSize: 14,
137
+ fontWeight: '400',
138
+ lineHeight: 20,
139
+ },
140
+ /** Small caps eyebrow above a value — "FOOD", "EXERCISE", "TOTAL". */
141
+ label: {
142
+ fontSize: 13,
143
+ fontWeight: '600',
144
+ lineHeight: 16,
145
+ letterSpacing: 0.4,
146
+ textTransform: 'uppercase',
147
+ },
148
+ /** Supporting text under a value. */
149
+ caption: {
150
+ fontSize: 12,
151
+ fontWeight: '500',
152
+ lineHeight: 16,
153
+ },
154
+ };