@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 +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 +15 -9
- 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.5.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,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=
|
|
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=
|
|
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=
|
|
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:
|
|
205
|
-
backgroundColor:
|
|
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:
|
|
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:
|
|
264
|
+
color: colors.text.secondary,
|
|
259
265
|
},
|
|
260
266
|
eyeIcon: {
|
|
261
267
|
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
|
+
};
|