@klyftig/mobile-ui 1.3.10 → 1.4.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 +4 -2
- package/src/components/AdaptiveGlassView.tsx +3 -1
- package/src/components/CenterDialog.tsx +1 -1
- package/src/components/ErrorBoundary.tsx +1 -1
- package/src/components/FAB.tsx +2 -2
- package/src/components/GlassButton.tsx +29 -37
- package/src/components/GlassCard.tsx +19 -8
- package/src/components/GlassContainer.tsx +5 -1
- package/src/components/GlassFooter.tsx +114 -0
- package/src/components/GlassInput.tsx +59 -29
- package/src/components/GlassList.tsx +1 -1
- package/src/components/GlassStats.tsx +5 -4
- package/src/components/ProgressCard.tsx +1 -1
- package/src/components/RecipeRow.tsx +3 -2
- package/src/components/SegmentedToggle.tsx +1 -1
- package/src/components/WheelPicker.tsx +7 -2
- package/src/components/progress/CArcProgress.tsx +2 -1
- package/src/components/progress/CircularProgress.tsx +12 -2
- package/src/index.ts +3 -1
- package/src/theme/colors.ts +107 -39
- package/src/theme/theme.ts +11 -2
- package/src/theme/typography.ts +101 -3
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klyftig/mobile-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"scripts": {
|
|
5
|
-
"
|
|
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
|
-
|
|
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}
|
package/src/components/FAB.tsx
CHANGED
|
@@ -198,7 +198,7 @@ const styles = StyleSheet.create({
|
|
|
198
198
|
fabFallback: {
|
|
199
199
|
backgroundColor: colors.glass.fabFallback,
|
|
200
200
|
elevation: 8,
|
|
201
|
-
shadowColor: '#
|
|
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: '#
|
|
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=
|
|
162
|
+
<ActivityIndicator color={colors.text.primary} size={isCompact ? 'small' : 'large'} />
|
|
160
163
|
) : (
|
|
161
164
|
<>
|
|
162
|
-
{icon && <Icon name={icon} size={isCompact ? 16 : 20} color=
|
|
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:
|
|
182
|
+
backgroundColor: colors.accent.brand,
|
|
180
183
|
borderWidth: 1,
|
|
181
184
|
borderColor: 'rgba(255,255,255,0.15)',
|
|
182
|
-
|
|
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:
|
|
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
|
-
|
|
198
|
-
|
|
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:
|
|
206
|
+
borderRadius: theme.borderRadius.card,
|
|
209
207
|
flexDirection: 'row',
|
|
210
208
|
alignItems: 'center',
|
|
211
209
|
justifyContent: 'center',
|
|
212
|
-
backgroundColor:
|
|
213
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
color: '#
|
|
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:
|
|
223
|
+
borderRadius: theme.borderRadius.card,
|
|
228
224
|
flexDirection: 'row',
|
|
229
225
|
alignItems: 'center',
|
|
230
226
|
justifyContent: 'center',
|
|
231
|
-
backgroundColor:
|
|
227
|
+
backgroundColor: colors.accent.brandSoft,
|
|
232
228
|
borderWidth: 1,
|
|
233
|
-
borderColor: 'rgba(
|
|
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:
|
|
234
|
+
borderRadius: theme.borderRadius.control,
|
|
244
235
|
paddingHorizontal: 20,
|
|
245
236
|
},
|
|
246
237
|
secondaryButtonText: {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
66
|
+
borderRadius: theme.borderRadius.card,
|
|
52
67
|
borderWidth: 1,
|
|
53
|
-
|
|
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
|
-
|
|
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,7 +1,9 @@
|
|
|
1
|
-
import React, { useState, useCallback,
|
|
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
4
|
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
4
5
|
import { useTheme } from '../hooks/useTheme';
|
|
6
|
+
import { colors } from '../theme/colors';
|
|
5
7
|
|
|
6
8
|
interface GlassInputProps {
|
|
7
9
|
label?: string;
|
|
@@ -13,6 +15,13 @@ interface GlassInputProps {
|
|
|
13
15
|
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
|
14
16
|
multiline?: boolean;
|
|
15
17
|
numberOfLines?: number;
|
|
18
|
+
// Only meaningful with multiline. Instead of `inputWrapperMultiline`'s fixed
|
|
19
|
+
// minHeight (96, a sensible default for a notes/description field), sizes
|
|
20
|
+
// the box to the actual content: compact for one short line, growing up to
|
|
21
|
+
// `numberOfLines` only when the value actually needs it. For a title-style
|
|
22
|
+
// field (e.g. a composite dish name) where most values are short, a fixed
|
|
23
|
+
// multi-line reservation looks oversized.
|
|
24
|
+
autoGrow?: boolean;
|
|
16
25
|
// Off by default for fields where iOS would otherwise replace partially-edited
|
|
17
26
|
// multi-word values with its autocorrect suggestion and snap the caret to end.
|
|
18
27
|
autoCorrect?: boolean;
|
|
@@ -35,6 +44,13 @@ interface GlassInputProps {
|
|
|
35
44
|
// so the caret follows taps and typing normally.
|
|
36
45
|
const SELECTION_START = { start: 0, end: 0 } as const;
|
|
37
46
|
|
|
47
|
+
// Matches `input`'s fontSize: 16 at RN's default ~1.2 line-height ratio.
|
|
48
|
+
// Used only by autoGrow to clamp the measured content height per line.
|
|
49
|
+
const LINE_HEIGHT = 20;
|
|
50
|
+
// inputWrapperAutoGrow's paddingVertical (12 top + 12 bottom) — added on top
|
|
51
|
+
// of the measured text height to get the wrapper's total minHeight.
|
|
52
|
+
const AUTO_GROW_PADDING_V = 24;
|
|
53
|
+
|
|
38
54
|
const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
39
55
|
label,
|
|
40
56
|
value,
|
|
@@ -56,29 +72,24 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
56
72
|
selectTextOnFocus,
|
|
57
73
|
multiline = false,
|
|
58
74
|
numberOfLines,
|
|
75
|
+
autoGrow = false,
|
|
59
76
|
}) => {
|
|
60
77
|
const { colors } = useTheme();
|
|
61
78
|
const [isSecure, setIsSecure] = useState(secureTextEntry);
|
|
62
79
|
const [isFocused, setIsFocused] = useState(false);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// genuine PROP change through normal React reconciliation to hit the native
|
|
70
|
-
// "set selection and scroll" command. A fresh object (new reference) fed
|
|
71
|
-
// through state a tick after mount, once layout has committed, does that.
|
|
72
|
-
const [delayedSelectionStart, setDelayedSelectionStart] = useState<typeof SELECTION_START | undefined>(undefined);
|
|
80
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
81
|
+
const isAutoGrow = multiline && autoGrow;
|
|
82
|
+
|
|
83
|
+
const handleContentSizeChange = useCallback((e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
|
84
|
+
setContentHeight(e.nativeEvent.contentSize.height);
|
|
85
|
+
}, []);
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return () => clearTimeout(id);
|
|
81
|
-
}, [value, isFocused, multiline]);
|
|
87
|
+
// Text-only height (no padding) — the wrapper's own paddingVertical is
|
|
88
|
+
// added on top via inputWrapperAutoGrow, matching how inputWrapperMultiline
|
|
89
|
+
// composes minHeight with its own paddingVertical for the fixed variant.
|
|
90
|
+
const growTextHeight = numberOfLines
|
|
91
|
+
? Math.min(Math.max(contentHeight, LINE_HEIGHT), numberOfLines * LINE_HEIGHT)
|
|
92
|
+
: Math.max(contentHeight, LINE_HEIGHT);
|
|
82
93
|
|
|
83
94
|
const handleFocus = useCallback(() => {
|
|
84
95
|
setIsFocused(true);
|
|
@@ -106,15 +117,21 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
106
117
|
disabled && styles.inputContainerDisabled,
|
|
107
118
|
]}
|
|
108
119
|
>
|
|
109
|
-
<View
|
|
120
|
+
<View
|
|
121
|
+
style={[
|
|
122
|
+
styles.inputWrapper,
|
|
123
|
+
multiline && !isAutoGrow && styles.inputWrapperMultiline,
|
|
124
|
+
isAutoGrow && [styles.inputWrapperAutoGrow, { minHeight: growTextHeight + AUTO_GROW_PADDING_V }],
|
|
125
|
+
]}
|
|
126
|
+
>
|
|
110
127
|
{icon && (
|
|
111
|
-
<Icon name={icon} size={20} color=
|
|
128
|
+
<Icon name={icon} size={20} color={colors.text.tertiary} style={styles.leftIcon} />
|
|
112
129
|
)}
|
|
113
130
|
<RNTextInput
|
|
114
131
|
value={value}
|
|
115
132
|
onChangeText={onChangeText}
|
|
116
133
|
placeholder={placeholder}
|
|
117
|
-
placeholderTextColor=
|
|
134
|
+
placeholderTextColor={colors.text.disabled}
|
|
118
135
|
secureTextEntry={isSecure}
|
|
119
136
|
keyboardType={keyboardType}
|
|
120
137
|
autoCapitalize={autoCapitalize}
|
|
@@ -123,8 +140,9 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
123
140
|
editable={!disabled}
|
|
124
141
|
onFocus={handleFocus}
|
|
125
142
|
onBlur={handleBlur}
|
|
143
|
+
onContentSizeChange={isAutoGrow ? handleContentSizeChange : undefined}
|
|
126
144
|
selectTextOnFocus={selectTextOnFocus}
|
|
127
|
-
selection={!isFocused && !multiline ?
|
|
145
|
+
selection={!isFocused && !multiline ? SELECTION_START : undefined}
|
|
128
146
|
multiline={multiline}
|
|
129
147
|
numberOfLines={numberOfLines}
|
|
130
148
|
textAlignVertical={multiline ? 'top' : undefined}
|
|
@@ -137,7 +155,8 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
137
155
|
styles.input,
|
|
138
156
|
icon && styles.inputWithLeftIcon,
|
|
139
157
|
(rightText || showPasswordToggle) && styles.inputWithRightIcon,
|
|
140
|
-
multiline && styles.inputMultiline,
|
|
158
|
+
multiline && !isAutoGrow && styles.inputMultiline,
|
|
159
|
+
isAutoGrow && [styles.inputAutoGrow, { height: growTextHeight }],
|
|
141
160
|
]}
|
|
142
161
|
/>
|
|
143
162
|
{rightText && (
|
|
@@ -154,7 +173,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
154
173
|
<Icon
|
|
155
174
|
name={isSecure ? 'eye-off' : 'eye'}
|
|
156
175
|
size={20}
|
|
157
|
-
color=
|
|
176
|
+
color={colors.text.tertiary}
|
|
158
177
|
/>
|
|
159
178
|
</TouchableOpacity>
|
|
160
179
|
)}
|
|
@@ -183,8 +202,8 @@ const styles = StyleSheet.create({
|
|
|
183
202
|
borderColor: 'rgba(0,0,0,0.08)',
|
|
184
203
|
},
|
|
185
204
|
inputContainerFocused: {
|
|
186
|
-
borderColor:
|
|
187
|
-
backgroundColor:
|
|
205
|
+
borderColor: colors.accent.brand,
|
|
206
|
+
backgroundColor: colors.background.card,
|
|
188
207
|
},
|
|
189
208
|
inputContainerDisabled: {
|
|
190
209
|
opacity: 0.6,
|
|
@@ -206,6 +225,17 @@ const styles = StyleSheet.create({
|
|
|
206
225
|
minHeight: 72,
|
|
207
226
|
paddingTop: 4,
|
|
208
227
|
},
|
|
228
|
+
// minHeight is applied inline (computed from measured content) — see
|
|
229
|
+
// isAutoGrow in the component.
|
|
230
|
+
inputWrapperAutoGrow: {
|
|
231
|
+
height: undefined,
|
|
232
|
+
paddingVertical: 12,
|
|
233
|
+
alignItems: 'flex-start',
|
|
234
|
+
},
|
|
235
|
+
// height is applied inline (computed from measured content).
|
|
236
|
+
inputAutoGrow: {
|
|
237
|
+
paddingTop: 0,
|
|
238
|
+
},
|
|
209
239
|
leftIcon: {
|
|
210
240
|
marginRight: 12,
|
|
211
241
|
},
|
|
@@ -214,7 +244,7 @@ const styles = StyleSheet.create({
|
|
|
214
244
|
fontSize: 16,
|
|
215
245
|
height: '100%',
|
|
216
246
|
paddingVertical: 0,
|
|
217
|
-
color:
|
|
247
|
+
color: colors.text.primary,
|
|
218
248
|
},
|
|
219
249
|
inputWithLeftIcon: {
|
|
220
250
|
marginLeft: 0,
|
|
@@ -226,7 +256,7 @@ const styles = StyleSheet.create({
|
|
|
226
256
|
fontSize: 16,
|
|
227
257
|
fontWeight: '600',
|
|
228
258
|
marginLeft: 8,
|
|
229
|
-
color:
|
|
259
|
+
color: colors.text.secondary,
|
|
230
260
|
},
|
|
231
261
|
eyeIcon: {
|
|
232
262
|
padding: 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:
|
|
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:
|
|
129
|
+
backgroundColor: colors.background.card,
|
|
129
130
|
borderWidth: 1,
|
|
130
131
|
borderColor: 'rgba(0,0,0,0.06)',
|
|
131
|
-
shadowColor: '#
|
|
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:
|
|
175
|
+
backgroundColor: colors.background.card,
|
|
175
176
|
},
|
|
176
177
|
macroGradient: {
|
|
177
178
|
padding: 16,
|
|
@@ -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: '#
|
|
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:
|
|
201
|
+
backgroundColor: colors.background.secondary,
|
|
201
202
|
},
|
|
202
203
|
thumbImage: {
|
|
203
204
|
width: '100%',
|
|
@@ -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
|
-
|
|
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:
|
|
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=
|
|
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=
|
|
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:
|
|
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';
|
package/src/theme/colors.ts
CHANGED
|
@@ -1,54 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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: '#
|
|
9
|
-
card: '#FFFFFF',
|
|
10
|
-
secondary: '#
|
|
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: ['#
|
|
26
|
+
gradient: ['#FAF9F6', '#FAF9F6', '#FAF9F6'] as const, // backward compat
|
|
13
27
|
},
|
|
14
28
|
text: {
|
|
15
|
-
primary: '#
|
|
16
|
-
secondary: '#
|
|
17
|
-
tertiary: '#
|
|
18
|
-
disabled: '#
|
|
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: '#
|
|
22
|
-
secondary: '#
|
|
23
|
-
tertiary: '#
|
|
35
|
+
primary: '#14140F',
|
|
36
|
+
secondary: '#6E6B63',
|
|
37
|
+
tertiary: '#A3A099',
|
|
24
38
|
},
|
|
25
39
|
border: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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: '#
|
|
48
|
+
backgroundLight: '#F2F1EC',
|
|
33
49
|
backgroundHeavy: '#FFFFFF',
|
|
34
50
|
tabBarFallback: '#FFFFFF',
|
|
35
|
-
|
|
36
|
-
|
|
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: '#
|
|
40
|
-
brand: '#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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: '#
|
|
49
|
-
carbs: '#
|
|
50
|
-
fat: '#
|
|
51
|
-
|
|
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: '#
|
|
63
|
-
shadowOffset: { width: 0, height:
|
|
64
|
-
shadowOpacity: 0.
|
|
65
|
-
shadowRadius:
|
|
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: '#
|
|
70
|
-
shadowOffset: { width: 0, height:
|
|
71
|
-
shadowOpacity: 0.
|
|
72
|
-
shadowRadius:
|
|
73
|
-
elevation:
|
|
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 },
|
package/src/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/theme/typography.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
};
|