@myazahq/kyc-sdk-react-native 2.2.0 → 2.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.
Files changed (51) hide show
  1. package/package.json +1 -2
  2. package/src/components/BrandBar.tsx +137 -0
  3. package/src/components/DialCodePicker.tsx +11 -17
  4. package/src/components/DocumentReviewSide.tsx +34 -14
  5. package/src/components/Icon.tsx +5 -0
  6. package/src/components/KycSheet.tsx +161 -180
  7. package/src/components/MediaSourceSheet.tsx +7 -37
  8. package/src/components/MyazaDateField.tsx +6 -20
  9. package/src/components/MyazaInput.tsx +6 -1
  10. package/src/components/MyazaSelect.tsx +20 -39
  11. package/src/components/PoweredBy.tsx +20 -15
  12. package/src/components/ProgressBar.tsx +91 -0
  13. package/src/components/SandboxBanner.tsx +92 -0
  14. package/src/components/StepHeader.tsx +15 -2
  15. package/src/components/StepIndicator.tsx +145 -31
  16. package/src/components/fonts.ts +23 -0
  17. package/src/components/glass/ChromeGlass.tsx +65 -0
  18. package/src/components/glass/FloatingSheet.tsx +190 -0
  19. package/src/components/glass/GlassSheet.tsx +38 -0
  20. package/src/components/glass/GlassSurface.tsx +31 -3
  21. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  22. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  23. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  24. package/src/config/questionnaire.ts +45 -4
  25. package/src/config/workflowMerge.ts +1 -0
  26. package/src/emrtd/crypto.ts +1 -1
  27. package/src/emrtd/dg1.ts +55 -0
  28. package/src/emrtd/ec-curves.ts +142 -0
  29. package/src/emrtd/ec.ts +196 -0
  30. package/src/emrtd/index.ts +1 -0
  31. package/src/emrtd/mrzKey.ts +21 -1
  32. package/src/emrtd/open.ts +169 -0
  33. package/src/emrtd/pace-params.ts +169 -0
  34. package/src/emrtd/pace.ts +295 -0
  35. package/src/emrtd/secureMessaging.ts +32 -14
  36. package/src/emrtd/session.ts +124 -20
  37. package/src/emrtd/suites.ts +99 -0
  38. package/src/index.ts +1 -0
  39. package/src/lib/step-log.ts +43 -0
  40. package/src/lib/step-window.ts +96 -0
  41. package/src/liveness/useLiveness.ts +1 -1
  42. package/src/screens/IdTypeStep.tsx +15 -2
  43. package/src/screens/NfcStep.tsx +12 -0
  44. package/src/screens/QuestionnaireField.tsx +30 -0
  45. package/src/screens/QuestionnaireStep.tsx +3 -1
  46. package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
  47. package/src/services/deviceMetadata.ts +9 -1
  48. package/src/store/derive.ts +7 -0
  49. package/src/store/kycStore.ts +25 -1
  50. package/src/types/config.ts +22 -0
  51. package/src/types/workflow.ts +10 -0
@@ -1,10 +1,12 @@
1
1
  import React, { useState } from 'react';
2
- import { Modal, Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
2
+ import { Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { useTheme } from './runtime';
6
+ import { useInputFontFamily } from './fonts';
6
7
  import { MyazaText } from './Typography';
7
8
  import { Icon } from './Icon';
9
+ import { FloatingSheet } from './glass/FloatingSheet';
8
10
 
9
11
  // ---------------------------------------------------------------------------
10
12
  // The select field — a collapsed value that opens a sheet of options.
@@ -72,6 +74,9 @@ export function MyazaSelect<T extends string | number>({
72
74
  searchable?: boolean;
73
75
  }): React.ReactElement {
74
76
  const { colors } = useTheme();
77
+ // TextInput does not inherit fontFamily in RN — set it or the search field
78
+ // (and its placeholder) renders in the system face.
79
+ const fontFamily = useInputFontFamily();
75
80
  const { height: windowHeight } = useWindowDimensions();
76
81
  const [open, setOpen] = useState(false);
77
82
  const [query, setQuery] = useState('');
@@ -127,45 +132,16 @@ export function MyazaSelect<T extends string | number>({
127
132
  <Icon name="chevron-down" size={18} color={colors.textSecondary} />
128
133
  </Pressable>
129
134
 
130
- <Modal
135
+ <FloatingSheet
131
136
  visible={open}
132
- transparent
133
- animationType="slide"
134
- onRequestClose={() => {
137
+ onClose={() => {
135
138
  setOpen(false);
136
139
  setQuery('');
137
140
  }}
141
+ // Long option lists stay inside the sheet and scroll.
142
+ maxHeight={windowHeight * 0.6}
143
+ closeLabel={`Close ${sheetTitle ?? hint}`}
138
144
  >
139
- {/* Tapping the backdrop dismisses, matching the platform sheet. */}
140
- <Pressable
141
- onPress={() => {
142
- setOpen(false);
143
- setQuery('');
144
- }}
145
- style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }}
146
- >
147
- {/* Stops a tap inside the sheet from closing it. */}
148
- <Pressable
149
- onPress={() => {}}
150
- style={{
151
- backgroundColor: colors.background,
152
- borderTopLeftRadius: radius.lg,
153
- borderTopRightRadius: radius.lg,
154
- paddingBottom: spacing.lg,
155
- // Long option lists stay inside the sheet and scroll.
156
- maxHeight: windowHeight * 0.6,
157
- }}
158
- >
159
- <View
160
- style={{
161
- alignSelf: 'center',
162
- width: 36,
163
- height: 4,
164
- borderRadius: radius.full,
165
- backgroundColor: colors.border,
166
- marginTop: spacing.sm,
167
- }}
168
- />
169
145
  <MyazaText
170
146
  variant="body"
171
147
  style={{
@@ -207,6 +183,7 @@ export function MyazaSelect<T extends string | number>({
207
183
  paddingHorizontal: spacing.sm,
208
184
  color: colors.textDark,
209
185
  fontSize: 15,
186
+ fontFamily,
210
187
  }}
211
188
  />
212
189
  {query !== '' ? (
@@ -217,7 +194,13 @@ export function MyazaSelect<T extends string | number>({
217
194
  </View>
218
195
  ) : null}
219
196
 
220
- <ScrollView bounces={false} keyboardShouldPersistTaps="handled">
197
+ <ScrollView
198
+ bounces={false}
199
+ keyboardShouldPersistTaps="handled"
200
+ // The floating shell has no bottom padding of its own, so the
201
+ // last option must not sit flush against the rounded corner.
202
+ contentContainerStyle={{ paddingBottom: spacing.lg }}
203
+ >
221
204
  {visible.length === 0 ? (
222
205
  <MyazaText
223
206
  variant="bodySmall"
@@ -275,9 +258,7 @@ export function MyazaSelect<T extends string | number>({
275
258
  );
276
259
  })}
277
260
  </ScrollView>
278
- </Pressable>
279
- </Pressable>
280
- </Modal>
261
+ </FloatingSheet>
281
262
  </>
282
263
  );
283
264
  }
@@ -45,17 +45,9 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
45
45
  alignItems: 'center',
46
46
  }}
47
47
  >
48
- {/* openURL can reject (no browser / a locked-down device). The mark itself
49
- is the point, so a failed open is swallowed rather than surfaced. */}
50
- <Pressable
51
- onPress={() => {
52
- void Linking.openURL(PRODUCT_URL).catch(() => undefined);
53
- }}
54
- accessibilityRole="link"
55
- accessibilityLabel="Powered by Myaza Trust"
56
- hitSlop={8}
57
- style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}
58
- >
48
+ {/* The ROW is not the link — only the mark is. "Powered by" is a label,
49
+ not a destination, so it does not carry the tap. */}
50
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, opacity: 0.9 }}>
59
51
  {/* Small and muted on purpose — "Powered by" is connective tissue, not
60
52
  the message. The BRAND carries the weight. */}
61
53
  <MyazaText variant="body" color={markColor} style={{ flexShrink: 1, fontSize: 12 }}>
@@ -63,8 +55,21 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
63
55
  </MyazaText>
64
56
 
65
57
  {/* The lockup, spaced TIGHTER than the gap before it so it reads as one
66
- mark rather than three evenly-spaced items. */}
67
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
58
+ mark rather than three evenly-spaced items. This is the link:
59
+ wordmark, rule and TRUST are one brand, so the whole lockup is the
60
+ target — but nothing beyond it is.
61
+
62
+ openURL can reject (no browser / a locked-down device). The mark
63
+ itself is the point, so a failed open is swallowed. */}
64
+ <Pressable
65
+ onPress={() => {
66
+ void Linking.openURL(PRODUCT_URL).catch(() => undefined);
67
+ }}
68
+ accessibilityRole="link"
69
+ accessibilityLabel="Myaza Trust"
70
+ hitSlop={8}
71
+ style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}
72
+ >
68
73
  <MyazaWordmark height={28} wordmark={markColor} />
69
74
 
70
75
  <View style={{ width: 1, height: 24, backgroundColor: markColor, opacity: 0.35 }} />
@@ -82,8 +87,8 @@ export function PoweredBy({ bottomInset = 0 }: { bottomInset?: number }): React.
82
87
  >
83
88
  TRUST
84
89
  </MyazaText>
85
- </View>
86
- </Pressable>
90
+ </Pressable>
91
+ </View>
87
92
  </View>
88
93
  );
89
94
  }
@@ -0,0 +1,91 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, Easing, useAnimatedValue, View } from 'react-native';
3
+
4
+ import { useTheme } from './runtime';
5
+
6
+ // The quiet alternative to StepIndicator: a single thin bar sitting ON the
7
+ // header's bottom edge, replacing its border rather than adding a row beneath
8
+ // it — so choosing it costs the header no height at all.
9
+ //
10
+ // Unlike the step circles it does not say WHICH step you are on or how many
11
+ // there are, which is the trade: it is unaffected by step count, so a 14-step
12
+ // KYB flow draws exactly like a 4-step one. Hosts who would rather the chrome
13
+ // said less opt in with `progressStyle: 'bar'`.
14
+
15
+ export interface ProgressBarProps {
16
+ /** 0.0–1.0 progress fraction. */
17
+ progress: number;
18
+ /** Steps in the flow — announced, not drawn. */
19
+ stepCount: number;
20
+ }
21
+
22
+ /**
23
+ * Thickness of the bar.
24
+ *
25
+ * 5, not the 1px border it replaces: at hairline weight it read as a rendering
26
+ * artefact rather than a deliberate indicator, and the filled portion needs
27
+ * enough body for its colour to register against the track at a glance.
28
+ */
29
+ const HEIGHT = 5;
30
+
31
+ export function ProgressBar({ progress, stepCount }: ProgressBarProps): React.ReactElement {
32
+ const { colors } = useTheme();
33
+ const fraction = Math.min(Math.max(progress, 0), 1);
34
+ const step = Math.min(Math.max(Math.round(fraction * stepCount), 1), stepCount);
35
+
36
+ // Animated so advancing a step reads as movement rather than a jump — the
37
+ // motion IS the feedback that the step was accepted. 250ms sits inside the
38
+ // 150–300ms micro-interaction band; ease-out because it is entering.
39
+ const width = useAnimatedValue(fraction);
40
+ const first = useRef(true);
41
+ useEffect(() => {
42
+ if (first.current) {
43
+ // Don't animate the initial mount from 0 — the flow may be resumed
44
+ // mid-way, and a bar sweeping in from empty would misreport where the
45
+ // user actually is.
46
+ first.current = false;
47
+ width.setValue(fraction);
48
+ return;
49
+ }
50
+ Animated.timing(width, {
51
+ toValue: fraction,
52
+ duration: 250,
53
+ easing: Easing.out(Easing.cubic),
54
+ // Width cannot be driven natively, and a scaleX transform would squash
55
+ // the rounded cap. The bar is one small view, so the JS driver is fine.
56
+ useNativeDriver: false,
57
+ }).start();
58
+ }, [fraction, width]);
59
+
60
+ return (
61
+ <View
62
+ style={{
63
+ position: 'absolute',
64
+ left: 0,
65
+ right: 0,
66
+ bottom: 0,
67
+ height: HEIGHT,
68
+ // The track doubles as the header's bottom border, which is why the
69
+ // header drops its own when this is shown.
70
+ backgroundColor: colors.border,
71
+ }}
72
+ accessible
73
+ accessibilityRole="progressbar"
74
+ accessibilityLabel={`Step ${step} of ${stepCount}`}
75
+ accessibilityValue={{ min: 1, max: stepCount, now: step }}
76
+ >
77
+ <Animated.View
78
+ style={{
79
+ height: HEIGHT,
80
+ backgroundColor: colors.primary,
81
+ borderTopRightRadius: HEIGHT / 2,
82
+ borderBottomRightRadius: HEIGHT / 2,
83
+ width: width.interpolate({
84
+ inputRange: [0, 1],
85
+ outputRange: ['0%', '100%'],
86
+ }),
87
+ }}
88
+ />
89
+ </View>
90
+ );
91
+ }
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import type { KycState } from '../store/state';
6
+ import { useKyc } from './runtime';
7
+ import { Icon } from './Icon';
8
+ import { MyazaText } from './Typography';
9
+
10
+ /**
11
+ * "You are not in production" strip, shown above the sheet's header.
12
+ *
13
+ * A sandbox flow is pixel-identical to a live one, which is the point — you are
14
+ * testing the real thing — and also the hazard: screenshots get mistaken for
15
+ * production incidents, testers wonder why a real passport was rejected, and a
16
+ * `pk_test_` key shipped to production looks like it works right up until
17
+ * nobody is actually verified.
18
+ *
19
+ * Environment comes from the SERVER (`/config`), not the API key prefix. Hosted
20
+ * sessions authenticate with an `hs_` handoff token that carries no environment
21
+ * slot, so key-sniffing would leave exactly the surface an end user sees
22
+ * unlabelled.
23
+ *
24
+ * DEVELOPMENT is labelled too, and differently: it runs the real pipeline
25
+ * against staging provider credentials, so "test data only" would be a lie
26
+ * there. 1:1 with the web and Flutter SDKs.
27
+ */
28
+ /**
29
+ * Whether the banner will render. The sheet needs this to decide who owns the
30
+ * safe-area top inset: the banner sits ABOVE the header, so when it shows it is
31
+ * the topmost element and must clear the status bar itself — otherwise the
32
+ * header pads for an inset the banner already crossed, and the strip is drawn
33
+ * under the clock on Android's edge-to-edge modals.
34
+ */
35
+ export function useSandboxBannerVisible(): boolean {
36
+ const environment = useKyc((s: KycState) => s.serverConfig.environment);
37
+ return environment === 'SANDBOX' || environment === 'DEVELOPMENT';
38
+ }
39
+
40
+ export function SandboxBanner({
41
+ topInset = 0,
42
+ }: {
43
+ /** Safe-area top inset to absorb, when the banner is the topmost element. */
44
+ topInset?: number;
45
+ }): React.ReactElement | null {
46
+ const environment = useKyc((s: KycState) => s.serverConfig.environment);
47
+ if (environment !== 'SANDBOX' && environment !== 'DEVELOPMENT') return null;
48
+
49
+ const sandbox = environment === 'SANDBOX';
50
+
51
+ return (
52
+ <View
53
+ accessibilityRole="alert"
54
+ style={{
55
+ flexDirection: 'row',
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ gap: spacing.xs + 2,
59
+ // Amber, not the brand colour: this is an out-of-band status about the
60
+ // session, and the org's palette would read as part of their flow.
61
+ backgroundColor: AMBER_TINT,
62
+ paddingHorizontal: spacing.md,
63
+ // The tint extends up through the status bar so the strip reads as one
64
+ // band rather than floating below a bare gap.
65
+ paddingTop: topInset + spacing.xs + 2,
66
+ paddingBottom: spacing.xs + 2,
67
+ }}
68
+ >
69
+ {/* Flask, not an alert mark: nothing is wrong — this is a statement about
70
+ WHICH environment you are in ("test environment", "test data only").
71
+ An alert glyph reads as a fault and sends testers hunting for one.
72
+ Same lucide icon the web SDK uses; Flutter mirrors it with
73
+ Icons.science_outlined. */}
74
+ <Icon name="flask" size={13} color={AMBER_INK} />
75
+ <MyazaText
76
+ variant="bodySmall"
77
+ color={AMBER_INK}
78
+ style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase' }}
79
+ >
80
+ {sandbox ? 'Sandbox' : 'Development'}
81
+ </MyazaText>
82
+ <MyazaText variant="bodySmall" color={AMBER_INK} style={{ fontSize: 11, flexShrink: 1 }}>
83
+ {sandbox ? 'Test data only, no real checks run' : 'Test environment, results are not live'}
84
+ </MyazaText>
85
+ </View>
86
+ );
87
+ }
88
+
89
+ // Fixed rather than themed: the strip must read identically in light and dark,
90
+ // and it deliberately does not belong to the org's palette.
91
+ const AMBER_TINT = 'rgba(245, 158, 11, 0.18)';
92
+ const AMBER_INK = '#B45309';
@@ -12,7 +12,10 @@ import { CountryFlag } from './CountryFlag';
12
12
  // title (heading2) + optional country flag, then an optional description below
13
13
  // (indented to align under the title when a back button is present).
14
14
 
15
- const BACK_FOOTPRINT = 28 + spacing.sm; // back button width + gap
15
+ // Back button width + gap. Kept in step with BACK_SIZE below: it indents the
16
+ // description so it starts under the title rather than under the button.
17
+ const BACK_SIZE = 40;
18
+ const BACK_FOOTPRINT = BACK_SIZE + spacing.sm;
16
19
 
17
20
  export interface StepHeaderProps {
18
21
  title: string;
@@ -28,7 +31,17 @@ export function StepHeader({ title, description, onBack, country }: StepHeaderPr
28
31
  <View style={{ flexDirection: 'row', alignItems: 'center' }}>
29
32
  {onBack ? (
30
33
  <View style={{ marginRight: spacing.sm }}>
31
- <GlassIconButton icon="back" onPress={onBack} size={28} iconSize={22} accessibilityLabel="Back" />
34
+ {/* Same 40pt footprint as the theme/close chrome above it — a
35
+ smaller glass circle here read as a different class of control,
36
+ and 28pt was under the 44pt minimum touch target even with
37
+ hitSlop. */}
38
+ <GlassIconButton
39
+ icon="back"
40
+ onPress={onBack}
41
+ size={BACK_SIZE}
42
+ iconSize={22}
43
+ accessibilityLabel="Back"
44
+ />
32
45
  </View>
33
46
  ) : null}
34
47
  <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
@@ -1,17 +1,23 @@
1
- import React from 'react';
2
- import { View } from 'react-native';
1
+ import React, { useState } from 'react';
2
+ import { PixelRatio, useWindowDimensions, View, type LayoutChangeEvent } from 'react-native';
3
3
 
4
4
  import { spacing } from '../config/theme';
5
+ import { fitStepCircles, windowedSteps, type StepSlot } from '../lib/step-window';
5
6
  import { useTheme } from './runtime';
6
7
  import { MyazaText } from './Typography';
7
8
  import { Icon } from './Icon';
8
9
 
9
10
  // Segmented numbered step indicator — 1:1 with the Flutter SDK's _StepIndicator.
10
- // N circles connected by thin lines:
11
- // • completed → filled primary + white check
12
- // • active → filled primary + white number
11
+ // N numbered circles connected by thin lines:
12
+ // • completed → filled primary + number + a check BADGE
13
+ // • active → filled primary + number
13
14
  // • upcoming → outlined (primary200) + muted number
14
15
  // activeIndex = round(progress * stepCount) - 1.
16
+ //
17
+ // Long flows COLLAPSE rather than overflow, but only as a LAST RESORT: the row
18
+ // measures itself and shows as many real circles as the width allows, windowing
19
+ // around the current step (first and last always shown) only once the connectors
20
+ // would stop reading as a chain. See lib/step-window.
15
21
 
16
22
  export interface StepIndicatorProps {
17
23
  /** 0.0–1.0 progress fraction. */
@@ -19,47 +25,155 @@ export interface StepIndicatorProps {
19
25
  stepCount: number;
20
26
  }
21
27
 
28
+ /** Base circle size, before text scaling. */
29
+ const CIRCLE = 26;
30
+
22
31
  export function StepIndicator({ progress, stepCount }: StepIndicatorProps): React.ReactElement {
23
32
  const { colors } = useTheme();
24
33
  const active = Math.round(progress * stepCount) - 1;
25
34
 
35
+ // Grow the circle with the system text size, or the number inside it clips
36
+ // the moment a user turns Dynamic Type up — the circle was a hard 26px while
37
+ // the label scaled freely. Capped at 1.4: past that the row matters less than
38
+ // the step content below it, and an indicator that pushes the title off screen
39
+ // helps nobody.
40
+ const scale = Math.min(PixelRatio.getFontScale(), 1.4);
41
+ const size = Math.round(CIRCLE * scale);
42
+ const badge = Math.round(13 * scale);
43
+
44
+ // How many circles actually fit. Measured rather than assumed, so a flow
45
+ // collapses on a narrow phone and stays whole on a wide one instead of both
46
+ // obeying the same hardcoded cap. The window width is the first estimate — it
47
+ // is right on the first frame, which avoids rendering a collapsed row and then
48
+ // snapping to a full one — and onLayout corrects it if the row is inset by
49
+ // anything this does not know about.
50
+ const { width: screenWidth } = useWindowDimensions();
51
+ const [measured, setMeasured] = useState(0);
52
+ const rowWidth = measured || Math.max(0, screenWidth - spacing.md * 2);
53
+ const slots = windowedSteps(stepCount, active, fitStepCircles(rowWidth, size));
54
+
55
+ const onLayout = (e: LayoutChangeEvent): void => {
56
+ const w = e.nativeEvent.layout.width - spacing.md * 2;
57
+ // Only react to real changes (rotation, split screen). Setting state on
58
+ // every layout pass would re-render the row for nothing.
59
+ setMeasured((prev) => (Math.abs(prev - w) > 1 ? w : prev));
60
+ };
61
+
26
62
  return (
27
- <View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md }}>
28
- {Array.from({ length: stepCount }).map((_, i) => {
29
- const completed = i < active;
30
- const isActive = i === active;
63
+ <View
64
+ style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md }}
65
+ onLayout={onLayout}
66
+ // ONE label for the whole row. Previously a screen reader walked ten
67
+ // unlabelled circles and announced a bare "6", which says nothing about
68
+ // how far through the flow that is.
69
+ accessible
70
+ accessibilityRole="progressbar"
71
+ accessibilityLabel={`Step ${Math.min(Math.max(active + 1, 1), stepCount)} of ${stepCount}`}
72
+ accessibilityValue={{
73
+ min: 1,
74
+ max: stepCount,
75
+ now: Math.min(Math.max(active + 1, 1), stepCount),
76
+ }}
77
+ >
78
+ {slots.map((slot: StepSlot, position) => {
79
+ const isEllipsis = slot === 'ellipsis';
80
+ const completed = !isEllipsis && slot < active;
81
+ const isActive = !isEllipsis && slot === active;
31
82
  const filled = completed || isActive;
32
83
  return (
33
- <React.Fragment key={i}>
34
- <View
35
- style={{
36
- width: 26,
37
- height: 26,
38
- borderRadius: 13,
39
- alignItems: 'center',
40
- justifyContent: 'center',
41
- backgroundColor: filled ? colors.primary : 'transparent',
42
- borderWidth: 1.5,
43
- borderColor: filled ? colors.primary : colors.primary200,
44
- }}
45
- >
46
- {completed ? (
47
- <Icon name="check" size={14} color="#FFFFFF" />
48
- ) : (
84
+ <React.Fragment key={isEllipsis ? `gap-${position}` : `step-${slot}`}>
85
+ {isEllipsis ? (
86
+ // Collapsed run. Sized to the circle's height so the connectors on
87
+ // either side stay on the same centre line and the chain reads as
88
+ // continuous rather than broken in two.
89
+ <View
90
+ style={{ height: size, justifyContent: 'center', paddingHorizontal: 2 }}
91
+ importantForAccessibility="no"
92
+ >
49
93
  <MyazaText
50
94
  variant="bodySmall"
51
- color={isActive ? '#FFFFFF' : colors.textMuted}
52
- style={{ fontSize: 11, fontWeight: '700' }}
95
+ color={colors.textMuted}
96
+ style={{ fontSize: 13, letterSpacing: 1 }}
97
+ allowFontScaling={false}
53
98
  >
54
- {i + 1}
99
+ ···
55
100
  </MyazaText>
56
- )}
57
- </View>
58
- {i < stepCount - 1 ? (
101
+ </View>
102
+ ) : (
103
+ // Positioning context for the badge, which straddles the circle's
104
+ // edge. Nothing here clips, so the overhang renders.
105
+ <View style={{ width: size, height: size }} importantForAccessibility="no">
106
+ <View
107
+ style={{
108
+ width: size,
109
+ height: size,
110
+ borderRadius: size / 2,
111
+ alignItems: 'center',
112
+ justifyContent: 'center',
113
+ backgroundColor: filled ? colors.primary : 'transparent',
114
+ borderWidth: 1.5,
115
+ borderColor: filled ? colors.primary : colors.primary200,
116
+ }}
117
+ >
118
+ {/* The NUMBER stays, completed or not. A check alone says a
119
+ step is done but not WHICH step — and once the row is
120
+ windowed ("1 ··· 5 6 7 ··· 10") that is precisely what the
121
+ numbers are there to answer. */}
122
+ <MyazaText
123
+ variant="bodySmall"
124
+ color={filled ? '#FFFFFF' : colors.textMuted}
125
+ style={{ fontSize: 12, fontWeight: '700' }}
126
+ // The circle already scales with the system text size, so
127
+ // scaling the glyph again would overflow it.
128
+ allowFontScaling={false}
129
+ >
130
+ {slot + 1}
131
+ </MyazaText>
132
+ </View>
133
+
134
+ {/* Completion rides as a badge tucked onto the circle's corner.
135
+ The ring is WHITE — the same colour as the number inside the
136
+ circle — because the badge sits on the circle, not on the
137
+ page. Ringing it in the page background (which the chip
138
+ portrait's CheckBadge does, correctly, because it sits on a
139
+ photo) painted a near-black blob around the check on the dark
140
+ theme, where background is #040218. */}
141
+ {completed ? (
142
+ <View
143
+ style={{
144
+ position: 'absolute',
145
+ top: -3,
146
+ right: -2,
147
+ width: badge,
148
+ height: badge,
149
+ borderRadius: badge / 2,
150
+ backgroundColor: colors.success,
151
+ // 1px, not 1.5: on a 13px badge a 1.5px ring eats nearly a
152
+ // quarter of the diameter and reads as a white outline
153
+ // rather than a hairline separating badge from circle.
154
+ borderWidth: 1,
155
+ borderColor: '#FFFFFF',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ }}
159
+ >
160
+ <Icon name="check" size={Math.round(badge * 0.6)} color="#FFFFFF" />
161
+ </View>
162
+ ) : null}
163
+ </View>
164
+ )}
165
+ {position < slots.length - 1 ? (
59
166
  <View
60
167
  style={{
61
168
  flex: 1,
169
+ // Floor, so an unforeseen overflow degrades to a visibly short
170
+ // connector rather than a row of circles with no chain at all.
171
+ minWidth: 6,
62
172
  height: 2,
173
+ // UNIFORM, deliberately: fitStepCircles budgets 3+3 per
174
+ // connector, so widening this for completed steps made the row
175
+ // need more space than was reserved. The badge is tucked in
176
+ // far enough (right: -2) not to need the extra.
63
177
  marginHorizontal: 3,
64
178
  borderRadius: 1,
65
179
  backgroundColor: completed ? colors.primary : colors.primary200,
@@ -1,6 +1,10 @@
1
+ import type { TextStyle } from 'react-native';
1
2
  import { useFonts } from 'expo-font';
2
3
  import { SpaceGrotesk_500Medium, SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
3
4
  import { Karla_400Regular, Karla_500Medium, Karla_600SemiBold, Karla_700Bold } from '@expo-google-fonts/karla';
5
+ import { fontFamilyFor } from '../config/font-resolve';
6
+ import { useTheme } from './theme-provider';
7
+
4
8
  export { fontFamilyFor, markFamilyName, brandFamilyName, BRAND_WEIGHTS } from '../config/font-resolve';
5
9
 
6
10
 
@@ -30,3 +34,22 @@ export function useMyazaFonts(): boolean {
30
34
  * Returns `undefined` until fonts are loaded so text falls back to the system
31
35
  * font + `fontWeight` without triggering an "unrecognized font" warning.
32
36
  */
37
+
38
+ /**
39
+ * The body font family for a TEXT INPUT, at the given weight.
40
+ *
41
+ * `TextInput` does not inherit `fontFamily` from any ancestor the way web
42
+ * inputs inherit from a stylesheet — RN resolves it per-element — so an input
43
+ * that never sets one renders in the system face while every `MyazaText`
44
+ * beside it renders in the brand's. The placeholder follows the input's own
45
+ * family too, which is why it looked wrong as well.
46
+ *
47
+ * Goes through the same resolver as MyazaText so an org's uploaded brand font
48
+ * reaches inputs, not just text. Returns undefined until fonts load, which is
49
+ * the caller's cue to leave `fontFamily` unset rather than name a family the
50
+ * platform has not registered.
51
+ */
52
+ export function useInputFontFamily(weight: TextStyle['fontWeight'] = '400'): string | undefined {
53
+ const { fontsLoaded, brandFonts } = useTheme();
54
+ return fontFamilyFor(false, weight, fontsLoaded, brandFonts);
55
+ }