@myazahq/kyc-sdk-react-native 2.1.0 → 2.3.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 (44) hide show
  1. package/README.md +43 -2
  2. package/package.json +1 -1
  3. package/src/components/Icon.tsx +3 -0
  4. package/src/components/KycSheet.tsx +209 -127
  5. package/src/components/MyazaInput.tsx +6 -1
  6. package/src/components/MyazaSelect.tsx +5 -0
  7. package/src/components/PoweredBy.tsx +20 -15
  8. package/src/components/ProgressBar.tsx +91 -0
  9. package/src/components/StepIndicator.tsx +145 -31
  10. package/src/components/fonts.ts +23 -0
  11. package/src/config/keyPeople.ts +10 -0
  12. package/src/config/questionnaire.ts +45 -4
  13. package/src/config/workflowMerge.ts +1 -0
  14. package/src/emrtd/crypto.ts +1 -1
  15. package/src/emrtd/dg1.ts +55 -0
  16. package/src/emrtd/ec-curves.ts +142 -0
  17. package/src/emrtd/ec.ts +196 -0
  18. package/src/emrtd/index.ts +1 -0
  19. package/src/emrtd/mrzKey.ts +21 -1
  20. package/src/emrtd/open.ts +169 -0
  21. package/src/emrtd/pace-params.ts +169 -0
  22. package/src/emrtd/pace.ts +295 -0
  23. package/src/emrtd/secureMessaging.ts +32 -14
  24. package/src/emrtd/session.ts +124 -20
  25. package/src/emrtd/suites.ts +99 -0
  26. package/src/index.ts +1 -0
  27. package/src/lib/step-log.ts +43 -0
  28. package/src/lib/step-window.ts +96 -0
  29. package/src/liveness/useLiveness.ts +1 -1
  30. package/src/screens/ApplicantRoleStep.tsx +1 -10
  31. package/src/screens/BusinessKeyPeopleStep.tsx +101 -45
  32. package/src/screens/IdTypeStep.tsx +15 -2
  33. package/src/screens/KeyPersonCard.tsx +119 -0
  34. package/src/screens/{BusinessKeyPersonRow.tsx → KeyPersonForm.tsx} +35 -57
  35. package/src/screens/KeyPersonSheet.tsx +184 -0
  36. package/src/screens/NfcStep.tsx +12 -0
  37. package/src/screens/QuestionnaireField.tsx +30 -0
  38. package/src/screens/QuestionnaireStep.tsx +3 -1
  39. package/src/screens/nfc/NfcSuccessPanel.tsx +13 -6
  40. package/src/services/deviceMetadata.ts +9 -1
  41. package/src/store/derive.ts +7 -0
  42. package/src/store/kycStore.ts +25 -1
  43. package/src/types/config.ts +17 -0
  44. package/src/types/workflow.ts +10 -0
package/README.md CHANGED
@@ -133,6 +133,47 @@ autolinked via React Native / Expo autolinking — no manual linking required.
133
133
  `children` (a string) to relabel it. For a fully custom trigger, use the
134
134
  [`useMyazaKYC()` hook](#trigger-component--hook).
135
135
 
136
+ ### Recommended — mount a workflow
137
+
138
+ Build the flow once in the Myaza dashboard as a **workflow**, then mount it by
139
+ id. The country, ID types, capture steps, add-ons, branding and copy all come
140
+ from the workflow, so changing the flow is a re-publish in the dashboard rather
141
+ than a new app build and an app-store review. See [Workflows](#workflows).
142
+
143
+ ```tsx
144
+ import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
145
+
146
+ export default function VerifyScreen() {
147
+ return (
148
+ <MyazaKYC
149
+ apiKey="pk_live_xxx" // prefix selects the env: pk_test_ → sandbox
150
+ workflowId="wf_AbC123dEf456"
151
+ // Runtime data — a workflow is a shared template and cannot carry any of it.
152
+ userId="usr_123"
153
+ userData={{ firstName: 'Jane', lastName: 'Doe' }}
154
+ metadata={{ orderId: 'ord_456' }}
155
+ onSubmit={(submission) => console.log('Submitted!', submission.verificationId)}
156
+ onError={(err) => console.warn('SDK error:', err.code, err.message)}
157
+ onClose={() => console.log('Modal closed')}
158
+ >
159
+ Verify my identity
160
+ </MyazaKYC>
161
+ );
162
+ }
163
+ ```
164
+
165
+ **`userData` is worth passing.** It is the name you believe the user has, and it
166
+ is compared against the name read off their document — that comparison is what
167
+ produces `dataMatch` on the verification. It cannot live on the workflow:
168
+ `userId`, `userData` and `metadata` are per-user runtime values, and a workflow
169
+ is a template shared by every visitor, so these stay in code even when
170
+ everything else moves to the dashboard.
171
+
172
+ ### Or configure everything in code
173
+
174
+ Skip the workflow and pass the flow's shape as props. Useful for a quick start
175
+ or a single fixed flow; anything you'd change later means shipping a new build.
176
+
136
177
  ```tsx
137
178
  import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
138
179
 
@@ -235,8 +276,8 @@ An unrecognized or malformed key throws at setup (it never silently defaults).
235
276
 
236
277
  ## Workflows
237
278
 
238
- Instead of configuring the flow in code, build it in the Myaza dashboard and
239
- reference it by id:
279
+ The recommended integration (see [Usage](#usage)): build the flow in the Myaza
280
+ dashboard and reference it by id —
240
281
 
241
282
  ```tsx
242
283
  <MyazaKYC apiKey="pk_live_xxx" workflowId="wf_abc123" userId="usr_123" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myazahq/kyc-sdk-react-native",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
5
5
  "author": "Flitstack Technologies Inc.",
6
6
  "repository": {
@@ -30,6 +30,7 @@ import {
30
30
  Moon,
31
31
  MoveLeft,
32
32
  Nfc,
33
+ Pencil,
33
34
  Smartphone,
34
35
  RefreshCw,
35
36
  ScanFace,
@@ -84,6 +85,7 @@ export type IconName =
84
85
  | 'scan-line'
85
86
  | 'scan-face'
86
87
  | 'nfc'
88
+ | 'pencil'
87
89
  | 'search'
88
90
  | 'zap'
89
91
  | 'link'
@@ -145,6 +147,7 @@ const ICONS: Record<IconName, LucideIcon> = {
145
147
  'scan-line': ScanLine,
146
148
  'scan-face': ScanFace,
147
149
  nfc: Nfc,
150
+ pencil: Pencil,
148
151
  search: Search,
149
152
  zap: Zap,
150
153
  'zap-off': ZapOff,
@@ -1,22 +1,23 @@
1
- import React, { useEffect, useState } from 'react';
2
- import { Image, Platform, ScrollView, StatusBar, View } from 'react-native';
3
- import { SvgXml } from 'react-native-svg';
4
- import { StatusBarController } from './StatusBarController';
5
- import { initialWindowMetrics } from 'react-native-safe-area-context';
1
+ import React, { useEffect, useState } from "react";
2
+ import { Image, Platform, ScrollView, StatusBar, View } from "react-native";
3
+ import { SvgXml } from "react-native-svg";
4
+ import { StatusBarController } from "./StatusBarController";
5
+ import { initialWindowMetrics } from "react-native-safe-area-context";
6
6
 
7
- import { headerSurface, radius, spacing } from '../config/theme';
8
- import { useKeyboardInset } from '../lib/use-keyboard-inset';
9
- import type { SupportedCountry } from '../types/config';
10
- import { useKycConfig, useTheme } from './runtime';
11
- import { useBranding } from './useBranding';
12
- import { MyazaText } from './Typography';
13
- import { PoweredBy } from './PoweredBy';
14
- import { StepHeader } from './StepHeader';
15
- import { StepIndicator } from './StepIndicator';
16
- import { GlassIconButton } from './GlassIconButton';
17
- import { GlassSurface } from './glass/GlassSurface';
18
- import { GlassGroup } from './glass/GlassGroup';
19
- import { FlashOverlay } from './FlashOverlay';
7
+ import { headerSurface, radius, spacing } from "../config/theme";
8
+ import { useKeyboardInset } from "../lib/use-keyboard-inset";
9
+ import type { SupportedCountry } from "../types/config";
10
+ import { useKycConfig, useTheme } from "./runtime";
11
+ import { useBranding } from "./useBranding";
12
+ import { MyazaText } from "./Typography";
13
+ import { PoweredBy } from "./PoweredBy";
14
+ import { StepHeader } from "./StepHeader";
15
+ import { StepIndicator } from "./StepIndicator";
16
+ import { ProgressBar } from "./ProgressBar";
17
+ import { GlassIconButton } from "./GlassIconButton";
18
+ import { GlassSurface } from "./glass/GlassSurface";
19
+ import { GlassGroup } from "./glass/GlassGroup";
20
+ import { FlashOverlay } from "./FlashOverlay";
20
21
 
21
22
  // Full-screen sheet shell — 1:1 with the Flutter SDK's KycBottomSheet:
22
23
  // • a tinted header block (Liquid Glass on iOS 26) with a bottom border:
@@ -83,16 +84,20 @@ export function KycSheet({
83
84
  const keyboardInset = useKeyboardInset();
84
85
 
85
86
  const tint = headerSurface(colors, mode);
86
- const showIndicator = progress != null && stepCount != null;
87
+ const hasProgress = progress != null && stepCount != null;
88
+ // The bar REPLACES the step row rather than joining it: two progress
89
+ // indicators on one header would be noise, and the point of the bar is that
90
+ // it costs no height.
91
+ const asBar = config.progressStyle === "bar";
92
+ const showIndicator = hasProgress && !asBar;
93
+ const showBar = hasProgress && asBar;
87
94
  const showBrand = !hideBrand && !!logoUri;
88
95
 
89
-
90
-
91
96
  // On iOS the modal is a pageSheet by default (sits below the status bar), but
92
97
  // `disableClose` presents it full-screen (to drop the swipe-to-dismiss), so it
93
98
  // then extends under the notch + over the home indicator and needs its own
94
99
  // safe-area insets — the pageSheet provides them for free.
95
- const iosFullScreen = Platform.OS === 'ios' && config.disableClose === true;
100
+ const iosFullScreen = Platform.OS === "ios" && config.disableClose === true;
96
101
 
97
102
  // Static safe-area snapshot (captured at app launch — no SafeAreaProvider
98
103
  // needed). On Android 15+/API 35+ the modal is drawn EDGE-TO-EDGE, and
@@ -106,7 +111,7 @@ export function KycSheet({
106
111
  // • iOS pageSheet: already below the status bar → no inset.
107
112
  // • iOS full-screen (disableClose): 56 covers the status bar + notch.
108
113
  const topInset =
109
- Platform.OS === 'android'
114
+ Platform.OS === "android"
110
115
  ? sa?.top || StatusBar.currentHeight || 28
111
116
  : iosFullScreen
112
117
  ? 56
@@ -114,95 +119,131 @@ export function KycSheet({
114
119
 
115
120
  // Clear the home indicator / nav bar at the bottom of a full-screen modal.
116
121
  const bottomInset =
117
- Platform.OS === 'android' ? sa?.bottom ?? 0 : iosFullScreen ? 34 : 0;
122
+ Platform.OS === "android" ? (sa?.bottom ?? 0) : iosFullScreen ? 34 : 0;
118
123
 
119
124
  return (
120
- <View style={{ flex: 1, backgroundColor: immersive ? '#000000' : colors.background }}>
125
+ <View
126
+ style={{
127
+ flex: 1,
128
+ backgroundColor: immersive ? "#000000" : colors.background,
129
+ }}
130
+ >
121
131
  {/* The SDK theme drives the status-bar glyph colour so it stays readable on
122
132
  the modal background — light glyphs in dark mode, dark in light mode.
123
133
  Android: JS StatusBar/SystemBars can't reach the <Modal>'s Dialog window,
124
134
  so a native view (StatusBarController) themes it from inside the modal.
125
135
  iOS: only the full-screen modal (disableClose) slides under the bar — the
126
136
  default pageSheet keeps the host bar above the card. */}
127
- {Platform.OS === 'android' ? (
128
- <StatusBarController barStyle={mode === 'dark' ? 'light' : 'dark'} />
137
+ {Platform.OS === "android" ? (
138
+ <StatusBarController barStyle={mode === "dark" ? "light" : "dark"} />
129
139
  ) : iosFullScreen ? (
130
- <StatusBar barStyle={mode === 'dark' ? 'light-content' : 'dark-content'} animated />
140
+ <StatusBar
141
+ barStyle={mode === "dark" ? "light-content" : "dark-content"}
142
+ animated
143
+ />
131
144
  ) : null}
132
145
  <View style={{ flex: 1 }}>
133
146
  {/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
134
147
  entirely when the step owns the display. */}
135
- {immersive ? null : <GlassSurface
136
- glassStyle="regular"
137
- fallbackColor={tint}
138
- style={{ borderBottomWidth: 1, borderBottomColor: colors.border, paddingBottom: spacing.md }}
139
- >
140
- {/* Row 1 — brand + controls */}
141
- <View
148
+ {immersive ? null : (
149
+ <GlassSurface
150
+ glassStyle="regular"
151
+ fallbackColor={tint}
142
152
  style={{
143
- flexDirection: 'row',
144
- alignItems: 'center',
145
- paddingHorizontal: spacing.md,
146
- paddingTop: topInset + spacing.sm,
153
+ // The bar sits ON this edge and paints its own track, so the
154
+ // border would double it.
155
+ borderBottomWidth: showBar ? 0 : 1,
156
+ borderBottomColor: colors.border,
157
+ paddingBottom: spacing.md,
147
158
  }}
148
159
  >
149
- <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
150
- {showBrand ? <BrandBar logoUri={logoUri!} companyName={companyName} /> : null}
151
- </View>
160
+ {/* Row 1 — brand + controls */}
161
+ <View
162
+ style={{
163
+ flexDirection: "row",
164
+ alignItems: "center",
165
+ paddingHorizontal: spacing.md,
166
+ paddingTop: topInset + spacing.sm,
167
+ }}
168
+ >
169
+ <View
170
+ style={{ flex: 1, flexDirection: "row", alignItems: "center" }}
171
+ >
172
+ {showBrand ? (
173
+ <BrandBar logoUri={logoUri!} companyName={companyName} />
174
+ ) : null}
175
+ </View>
152
176
 
153
- {/* Header controls. When BOTH the theme toggle and close show, they
177
+ {/* Header controls. When BOTH the theme toggle and close show, they
154
178
  share a single Liquid Glass capsule (each button `plain`). When
155
179
  only one shows — the consumer disabled the other (`disableClose`
156
180
  or `showThemeToggle: false`) — that button stands on its own
157
181
  glass instead of a one-icon capsule. Neither → nothing. */}
158
- {(() => {
159
- const showToggle = config.showThemeToggle !== false;
160
- const showClose = !config.disableClose;
161
- const toggleBtn = showToggle ? (
162
- <GlassIconButton
163
- icon={mode === 'dark' ? 'sun' : 'moon'}
164
- onPress={toggle}
165
- solid
166
- plain={showClose}
167
- accessibilityLabel="Toggle theme"
168
- />
169
- ) : null;
170
- const closeBtn = showClose ? (
171
- <GlassIconButton
172
- icon="close"
173
- onPress={onClose}
174
- plain={showToggle}
175
- accessibilityLabel="Close"
176
- />
177
- ) : null;
182
+ {(() => {
183
+ const showToggle = config.showThemeToggle !== false;
184
+ const showClose = !config.disableClose;
185
+ const toggleBtn = showToggle ? (
186
+ <GlassIconButton
187
+ icon={mode === "dark" ? "sun" : "moon"}
188
+ onPress={toggle}
189
+ solid
190
+ plain={showClose}
191
+ accessibilityLabel="Toggle theme"
192
+ />
193
+ ) : null;
194
+ const closeBtn = showClose ? (
195
+ <GlassIconButton
196
+ icon="close"
197
+ onPress={onClose}
198
+ plain={showToggle}
199
+ accessibilityLabel="Close"
200
+ />
201
+ ) : null;
178
202
 
179
- if (showToggle && showClose) {
180
- return (
181
- <GlassGroup style={{ gap: 2 }}>
182
- {toggleBtn}
183
- {closeBtn}
184
- </GlassGroup>
185
- );
186
- }
187
- // Exactly one (or zero) — render it standalone with its own glass.
188
- return toggleBtn ?? closeBtn;
189
- })()}
190
- </View>
191
-
192
- {/* Row 2 — title + back + flag */}
193
- {title || onBack ? (
194
- <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}>
195
- <StepHeader title={title} description={description} onBack={onBack} country={country} />
203
+ if (showToggle && showClose) {
204
+ return (
205
+ <GlassGroup style={{ gap: 2 }}>
206
+ {toggleBtn}
207
+ {closeBtn}
208
+ </GlassGroup>
209
+ );
210
+ }
211
+ // Exactly one (or zero) — render it standalone with its own glass.
212
+ return toggleBtn ?? closeBtn;
213
+ })()}
196
214
  </View>
197
- ) : null}
198
215
 
199
- {/* Row 3 — step indicator */}
200
- {showIndicator ? (
201
- <View style={{ marginTop: spacing.md }}>
202
- <StepIndicator progress={progress!} stepCount={stepCount!} />
203
- </View>
204
- ) : null}
205
- </GlassSurface>}
216
+ {/* Row 2 — title + back + flag */}
217
+ {title || onBack ? (
218
+ <View
219
+ style={{
220
+ paddingHorizontal: spacing.md,
221
+ paddingTop: spacing.sm,
222
+ }}
223
+ >
224
+ <StepHeader
225
+ title={title}
226
+ description={description}
227
+ onBack={onBack}
228
+ country={country}
229
+ />
230
+ </View>
231
+ ) : null}
232
+
233
+ {/* Row 3 — step indicator */}
234
+ {showIndicator ? (
235
+ <View style={{ marginTop: spacing.md }}>
236
+ <StepIndicator progress={progress!} stepCount={stepCount!} />
237
+ </View>
238
+ ) : null}
239
+
240
+ {/* Absolutely positioned, so it overlays the header's bottom edge
241
+ instead of adding a row — the whole reason to prefer it. */}
242
+ {showBar ? (
243
+ <ProgressBar progress={progress!} stepCount={stepCount!} />
244
+ ) : null}
245
+ </GlassSurface>
246
+ )}
206
247
 
207
248
  {/* Step body */}
208
249
  {fillsViewport ? (
@@ -210,7 +251,9 @@ export function KycSheet({
210
251
  // padding exists so scrolled content clears the home indicator, but
211
252
  // in a fixed-height body it just lifts the list off the footer and
212
253
  // reads as a dead band. PoweredBy owns the safe-area inset already.
213
- <View style={{ flex: 1, padding: spacing.md, paddingBottom: spacing.md }}>
254
+ <View
255
+ style={{ flex: 1, padding: spacing.md, paddingBottom: spacing.md }}
256
+ >
214
257
  {children}
215
258
  </View>
216
259
  ) : (
@@ -236,7 +279,9 @@ export function KycSheet({
236
279
  // input into view — the platform's own handling, which a
237
280
  // KeyboardAvoidingView only approximates. Without this the
238
281
  // keyboard simply covered the bottom of every input step.
239
- automaticallyAdjustKeyboardInsets={Platform.OS === 'ios' && !immersive}
282
+ automaticallyAdjustKeyboardInsets={
283
+ Platform.OS === "ios" && !immersive
284
+ }
240
285
  >
241
286
  {children}
242
287
  </ScrollView>
@@ -265,14 +310,20 @@ export function KycSheet({
265
310
 
266
311
  /** Whether a logo URI points at an SVG (extension check, query-safe). */
267
312
  function isSvgUri(uri: string): boolean {
268
- const path = uri.split('?')[0] ?? uri;
269
- return path.toLowerCase().endsWith('.svg');
313
+ const path = uri.split("?")[0] ?? uri;
314
+ return path.toLowerCase().endsWith(".svg");
270
315
  }
271
316
 
272
317
  // Persistent header brand — circular logo avatar + company name. A broken/missing
273
318
  // logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
274
319
  // Flutter's `errorBuilder`), so the header never shows a broken-image box.
275
- function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: string }): React.ReactElement | null {
320
+ function BrandBar({
321
+ logoUri,
322
+ companyName,
323
+ }: {
324
+ logoUri: string;
325
+ companyName: string;
326
+ }): React.ReactElement | null {
276
327
  const { colors } = useTheme();
277
328
  const [broken, setBroken] = useState(false);
278
329
  const svg = isSvgUri(logoUri);
@@ -285,7 +336,9 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
285
336
  if (!svg) return;
286
337
  let cancelled = false;
287
338
  fetch(logoUri)
288
- .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
339
+ .then((r) =>
340
+ r.ok ? r.text() : Promise.reject(new Error(String(r.status))),
341
+ )
289
342
  .then((text) => {
290
343
  if (!cancelled) setXml(text);
291
344
  })
@@ -299,54 +352,83 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
299
352
  if (broken) return null;
300
353
  return (
301
354
  <>
355
+ {/* TWO layers, deliberately.
356
+
357
+ A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
358
+ overflow-hidden compiles to `masksToBounds`, which clips the shadow
359
+ away with everything else outside the bounds. Android draws `elevation`
360
+ outside the view, so it survived there — which is why the chip lost its
361
+ ring on iPhone only.
362
+
363
+ So the outer view owns the border and shadow and does NOT clip, and the
364
+ inner one clips the logo. Same split Flutter gets for free by putting
365
+ the border on the Container's decoration and the crop in a ClipOval. */}
302
366
  <View
303
367
  style={{
304
368
  width: 28,
305
369
  height: 28,
306
370
  borderRadius: radius.full,
307
- backgroundColor: '#FFFFFF',
308
- // Light border + soft shadow — the same treatment as the Flutter
309
- // SDK's brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and
310
- // the web SDK's ring-1 ring-black/5.
371
+ backgroundColor: "#FFFFFF",
372
+ // Light ring + soft shadow — the same treatment as the Flutter SDK's
373
+ // brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
374
+ // SDK's ring-1 ring-black/5.
311
375
  borderWidth: 1,
312
- borderColor: 'rgba(0,0,0,0.05)',
313
- shadowColor: '#000000',
376
+ borderColor: "rgba(0,0,0,0.05)",
377
+ shadowColor: "#000000",
314
378
  shadowOpacity: 0.06,
315
379
  shadowRadius: 4,
316
380
  shadowOffset: { width: 0, height: 1 },
317
381
  elevation: 1,
318
- overflow: 'hidden',
319
382
  marginRight: spacing.sm,
320
- alignItems: 'center',
321
- justifyContent: 'center',
383
+ alignItems: "center",
384
+ justifyContent: "center",
322
385
  }}
323
386
  >
324
- {/* Fill + cover-crop the circle. The borderRadius is repeated on the
325
- Image itself because iOS doesn't reliably clip an Image child to a
326
- parent's rounded corners (mirrors Flutter's ClipOval + cover).
327
- SVG logos (common — the brand import picks up site favicons) can't
328
- be decoded by <Image>; the fetched markup renders via SvgXml at
329
- the chip's inner size (26 = 28 minus the 1px border each side),
330
- slice = cover-crop. */}
331
- {svg ? (
332
- xml ? (
333
- <SvgXml
334
- xml={xml}
335
- width={26}
336
- height={26}
337
- preserveAspectRatio="xMidYMid slice"
387
+ <View
388
+ style={{
389
+ // Fills the parent's CONTENT box (inside its 1px border) and is the
390
+ // only thing that clips, so the logo reaches the ring on every side.
391
+ alignSelf: "stretch",
392
+ flex: 1,
393
+ borderRadius: radius.full,
394
+ overflow: "hidden",
395
+ }}
396
+ >
397
+ {/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
398
+
399
+ Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
400
+ number was "28 minus the 1px border on each side", so it silently
401
+ went wrong the moment either value changed, and it left the logo a
402
+ pixel short of the ring. The clipping parent now owns the geometry.
403
+
404
+ SVG logos are common here because the brand import picks up site
405
+ favicons, and <Image> cannot decode SVG at all — hence the fetched
406
+ markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
407
+ {svg ? (
408
+ xml ? (
409
+ <SvgXml
410
+ xml={xml}
411
+ width="100%"
412
+ height="100%"
413
+ preserveAspectRatio="xMidYMid slice"
414
+ />
415
+ ) : null
416
+ ) : (
417
+ <Image
418
+ source={{ uri: logoUri }}
419
+ style={{ width: "100%", height: "100%" }}
420
+ resizeMode="cover"
421
+ onError={() => setBroken(true)}
338
422
  />
339
- ) : null
340
- ) : (
341
- <Image
342
- source={{ uri: logoUri }}
343
- style={{ width: '100%', height: '100%', borderRadius: radius.full }}
344
- resizeMode="cover"
345
- onError={() => setBroken(true)}
346
- />
347
- )}
423
+ )}
424
+ </View>
348
425
  </View>
349
- <MyazaText variant="heading3" numberOfLines={1} color={colors.textDark} style={{ flexShrink: 1, fontSize: 14 }}>
426
+ <MyazaText
427
+ variant="heading3"
428
+ numberOfLines={1}
429
+ color={colors.textDark}
430
+ style={{ flexShrink: 1, fontSize: 14 }}
431
+ >
350
432
  {companyName}
351
433
  </MyazaText>
352
434
  </>
@@ -11,6 +11,7 @@ type BlurHandler = NonNullable<TextInputProps['onBlur']>;
11
11
 
12
12
  import { radius, sizing, spacing } from '../config/theme';
13
13
  import { useTheme } from './runtime';
14
+ import { useInputFontFamily } from './fonts';
14
15
  import { MyazaText } from './Typography';
15
16
 
16
17
  // Branded text input with label, helper, and error states. Mirrors the Flutter
@@ -60,6 +61,9 @@ export function MyazaInput({
60
61
  onBlur,
61
62
  }: MyazaInputProps): React.ReactElement {
62
63
  const { colors } = useTheme();
64
+ // TextInput does not inherit fontFamily in RN — set it or the field (and its
65
+ // placeholder) renders in the system face beside brand-font text.
66
+ const fontFamily = useInputFontFamily();
63
67
  const [focused, setFocused] = useState(false);
64
68
 
65
69
  // Mirror the Flutter SDK's MyazaInput border states:
@@ -113,7 +117,7 @@ export function MyazaInput({
113
117
  autoFocus={autoFocus}
114
118
  onFocus={handleFocus}
115
119
  onBlur={handleBlur}
116
- style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16 }}
120
+ style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16, fontFamily }}
117
121
  />
118
122
  {suffix ? <View style={{ width: spacing.sm }} /> : null}
119
123
  {suffix}
@@ -142,6 +146,7 @@ export function MyazaInput({
142
146
  color: colors.textDark,
143
147
  backgroundColor: colors.background,
144
148
  fontSize: 16,
149
+ fontFamily,
145
150
  }}
146
151
  />
147
152
  )}
@@ -3,6 +3,7 @@ import { Modal, Pressable, ScrollView, TextInput, useWindowDimensions, View } fr
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';
8
9
 
@@ -72,6 +73,9 @@ export function MyazaSelect<T extends string | number>({
72
73
  searchable?: boolean;
73
74
  }): React.ReactElement {
74
75
  const { colors } = useTheme();
76
+ // TextInput does not inherit fontFamily in RN — set it or the search field
77
+ // (and its placeholder) renders in the system face.
78
+ const fontFamily = useInputFontFamily();
75
79
  const { height: windowHeight } = useWindowDimensions();
76
80
  const [open, setOpen] = useState(false);
77
81
  const [query, setQuery] = useState('');
@@ -207,6 +211,7 @@ export function MyazaSelect<T extends string | number>({
207
211
  paddingHorizontal: spacing.sm,
208
212
  color: colors.textDark,
209
213
  fontSize: 15,
214
+ fontFamily,
210
215
  }}
211
216
  />
212
217
  {query !== '' ? (
@@ -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
  }