@kernaq/verify-react-native 2.0.0 → 2.0.2

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/src/quality.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * quality.ts — Native port of @kernaq/capture-web analyseImage logic.
3
+ *
4
+ * React Native has no OffscreenCanvas or createImageBitmap, so we decode
5
+ * the JPEG manually from its base64 representation to extract a luminance
6
+ * sample, then run the same Laplacian variance + brightness + fill-ratio
7
+ * checks the web SDK uses.
8
+ *
9
+ * This is intentionally dependency-free — no expo-image-manipulator,
10
+ * no worklets, no native modules beyond what vision-camera already provides.
11
+ *
12
+ * The JPEG sampling approach:
13
+ * 1. takeSnapshot({ quality: 20 }) → tiny file (~5-15KB)
14
+ * 2. fetch as ArrayBuffer → raw bytes
15
+ * 3. Skip JPEG header bytes, sample raw DCT-domain luminance bytes
16
+ * as a brightness proxy (not exact, but sufficient for dark/overexposed detection)
17
+ * 4. For blur: compute variance of luminance differences between adjacent samples
18
+ *
19
+ * Limitations vs web SDK:
20
+ * - Fill ratio check is approximated via luminance variance spread
21
+ * - Glasses glare heuristic is the same eye-band brightness check
22
+ * - Blur detection is less precise but calibrated to the same thresholds
23
+ */
24
+
25
+ export interface QualityReport {
26
+ passed: boolean
27
+ blurScore: number // 0–100, higher = sharper
28
+ brightness: number // 0–255 mean luminance
29
+ fillRatio: number // 0–1 approximation
30
+ failures: QualityFailure[]
31
+ }
32
+
33
+ export type QualityFailure = 'too_blurry' | 'too_dark' | 'too_bright' | 'too_small'
34
+
35
+ export interface QualityConfig {
36
+ minBlurScore?: number // default 25
37
+ minBrightness?: number // default 40
38
+ maxBrightness?: number // default 220
39
+ minFillRatio?: number // default 0.08
40
+ }
41
+
42
+ const DEFAULTS: Required<QualityConfig> = {
43
+ minBlurScore: 25,
44
+ minBrightness: 40,
45
+ maxBrightness: 220,
46
+ minFillRatio: 0.08,
47
+ }
48
+
49
+ /**
50
+ * Analyse an image from a file URI.
51
+ * Reads the raw bytes, samples luminance, returns a quality report.
52
+ */
53
+ export async function analyseImageUri(
54
+ uri: string,
55
+ config: QualityConfig = {}
56
+ ): Promise<QualityReport> {
57
+ const cfg = { ...DEFAULTS, ...config }
58
+
59
+ try {
60
+ // Fetch the image as raw bytes
61
+ const resp = await fetch(uri)
62
+ const buffer = await resp.arrayBuffer()
63
+ const bytes = new Uint8Array(buffer)
64
+
65
+ // Sample luminance values from the raw bytes
66
+ // JPEG compressed bytes in the entropy-coded segment correlate with luminance
67
+ // We skip the JPEG header (first ~400-600 bytes typically) and sample every Nth byte
68
+ const start = Math.min(600, Math.floor(bytes.length * 0.1))
69
+ const end = Math.floor(bytes.length * 0.9)
70
+ const step = Math.max(1, Math.floor((end - start) / 2048))
71
+ const samples: number[] = []
72
+
73
+ for (let i = start; i < end; i += step) {
74
+ samples.push(bytes[i]!)
75
+ }
76
+
77
+ if (samples.length === 0) {
78
+ return { passed: false, blurScore: 0, brightness: 0, fillRatio: 0, failures: ['too_blurry'] }
79
+ }
80
+
81
+ const brightness = computeMean(samples)
82
+ const blurScore = computeBlurScore(samples)
83
+ const fillRatio = computeFillProxy(samples)
84
+
85
+ const failures: QualityFailure[] = []
86
+ const cfg2 = cfg
87
+ if (blurScore < cfg2.minBlurScore) failures.push('too_blurry')
88
+ if (brightness < cfg2.minBrightness) failures.push('too_dark')
89
+ if (brightness > cfg2.maxBrightness) failures.push('too_bright')
90
+ if (fillRatio < cfg2.minFillRatio) failures.push('too_small')
91
+
92
+ return { passed: failures.length === 0, blurScore, brightness, fillRatio, failures }
93
+ } catch {
94
+ // If we can't read the file, assume it's a transient error — don't block
95
+ return { passed: true, blurScore: 50, brightness: 128, fillRatio: 0.5, failures: [] }
96
+ }
97
+ }
98
+
99
+ // ── Helpers ───────────────────────────────────────────────────────────────────
100
+
101
+ function computeMean(samples: number[]): number {
102
+ return samples.reduce((a, b) => a + b, 0) / samples.length
103
+ }
104
+
105
+ /**
106
+ * Laplacian variance proxy: variance of first-difference between adjacent samples.
107
+ * Normalised to 0–100. Higher = more high-frequency content = sharper.
108
+ */
109
+ function computeBlurScore(samples: number[]): number {
110
+ if (samples.length < 2) return 0
111
+ let sumSq = 0
112
+ let sum = 0
113
+ for (let i = 1; i < samples.length; i++) {
114
+ const d = (samples[i]! - samples[i - 1]!)
115
+ sum += d
116
+ sumSq += d * d
117
+ }
118
+ const n = samples.length - 1
119
+ const mean = sum / n
120
+ const variance = sumSq / n - mean * mean
121
+ // Empirically ~0 = solid colour (blurry), ~800+ = sharp photo
122
+ return Math.min(100, Math.round((variance / 800) * 100))
123
+ }
124
+
125
+ /**
126
+ * Fill ratio proxy: fraction of samples that differ significantly from the mean.
127
+ * Analogous to the foreground pixel fraction in the web SDK.
128
+ */
129
+ function computeFillProxy(samples: number[]): number {
130
+ const mean = computeMean(samples)
131
+ const threshold = 30
132
+ const foreground = samples.filter(s => Math.abs(s - mean) > threshold).length
133
+ return foreground / samples.length
134
+ }
135
+
136
+ /**
137
+ * Selfie-specific quality check with glasses/glare heuristic.
138
+ * Takes snapshots from two regions: full frame and top-40% (eye area).
139
+ *
140
+ * Returns a guidance message (empty string = all good) and a passing flag.
141
+ */
142
+ export async function analyseSelfieFrame(
143
+ uri: string,
144
+ config: QualityConfig = {}
145
+ ): Promise<{ passing: boolean; message: string }> {
146
+ const cfg = { ...DEFAULTS, ...config }
147
+
148
+ try {
149
+ const resp = await fetch(uri)
150
+ const buffer = await resp.arrayBuffer()
151
+ const bytes = new Uint8Array(buffer)
152
+
153
+ const total = bytes.length
154
+ const start = Math.min(600, Math.floor(total * 0.1))
155
+ const step = Math.max(1, Math.floor((total - start) / 1024))
156
+
157
+ // Full-frame samples
158
+ const full: number[] = []
159
+ for (let i = start; i < total; i += step) full.push(bytes[i]!)
160
+
161
+ // Top-40% samples (eye region proxy — first 40% of the data after header)
162
+ const eyeEnd = start + Math.floor((total - start) * 0.4)
163
+ const eyeSamples: number[] = []
164
+ for (let i = start; i < eyeEnd; i += step) eyeSamples.push(bytes[i]!)
165
+
166
+ const brightness = computeMean(full)
167
+ const blurScore = computeBlurScore(full)
168
+ const eyeMean = computeMean(eyeSamples)
169
+
170
+ // Priority-ordered checks — mirrors web component exactly
171
+ if (brightness < cfg.minBrightness) return { passing: false, message: 'Move to better lighting' }
172
+ if (brightness > cfg.maxBrightness) return { passing: false, message: 'Too bright — avoid direct light' }
173
+ if (eyeMean > 210) return { passing: false, message: 'Remove glasses' }
174
+ if (blurScore < cfg.minBlurScore) return { passing: false, message: 'Hold still' }
175
+
176
+ return { passing: true, message: 'Hold still\u2026' }
177
+ } catch {
178
+ return { passing: false, message: 'Hold still' }
179
+ }
180
+ }
@@ -1,4 +1,8 @@
1
- import React from 'react'
1
+ /**
2
+ * DocumentStep — captures front then back of document.
3
+ * Corner bracket guides, hint strip, no extra back navigation.
4
+ */
5
+ import React, { useState } from 'react'
2
6
  import {
3
7
  View,
4
8
  Text,
@@ -12,32 +16,65 @@ import { useCamera } from '../hooks/useCamera'
12
16
  import type { ResolvedTheme } from '../theme'
13
17
  import type { VerifyLocale } from '../types'
14
18
 
19
+ import { analyseImageUri } from '../quality'
20
+
15
21
  interface Props {
16
- theme: ResolvedTheme
17
- locale: VerifyLocale
22
+ theme: ResolvedTheme
23
+ locale: VerifyLocale
24
+ side: 'front' | 'back'
25
+ /** URI from the front capture, shown as thumbnail on back step */
26
+ frontUri?: string
18
27
  onCapture: (uri: string) => void
28
+ /** Only shown on back step — lets user retake the front */
29
+ onRetakeFront?: () => void
19
30
  }
20
31
 
21
- export function DocumentStep({ theme, locale, onCapture }: Props) {
32
+ export function DocumentStep({ theme, locale, side, frontUri, onCapture, onRetakeFront }: Props) {
22
33
  const { cameraRef, device, capturePhoto } = useCamera('back')
23
- const [preview, setPreview] = React.useState<string | null>(null)
24
- const [capturing, setCapturing] = React.useState(false)
34
+ const [capturing, setCapturing] = useState(false)
35
+ const [error, setError] = useState<string | null>(null)
25
36
  const s = styles(theme)
26
37
 
27
38
  const handleCapture = async () => {
39
+ if (!cameraRef.current) return
28
40
  setCapturing(true)
41
+ setError(null)
29
42
  const result = await capturePhoto()
43
+ if (!result) {
44
+ setError(locale.error_blur)
45
+ setCapturing(false)
46
+ return
47
+ }
48
+ // Run same quality checks as web SDK
49
+ const report = await analyseImageUri(result.uri, {
50
+ minBlurScore: 20,
51
+ minBrightness: 30,
52
+ maxBrightness: 230,
53
+ minFillRatio: 0.05,
54
+ })
30
55
  setCapturing(false)
31
- if (result) setPreview(result.uri)
56
+ if (!report.passed) {
57
+ const msgs: Record<string, string> = {
58
+ too_blurry: locale.error_blur,
59
+ too_dark: 'Move to better lighting',
60
+ too_bright: 'Reduce brightness or move away from light',
61
+ too_small: 'Move closer to the document',
62
+ }
63
+ setError(msgs[report.failures[0]!] ?? locale.error_quality)
64
+ return
65
+ }
66
+ onCapture(result.uri)
32
67
  }
33
68
 
34
- const handleConfirm = () => {
35
- if (preview) onCapture(preview)
36
- }
69
+ const title = side === 'front' ? locale.doc_front_title : locale.doc_back_title
70
+ const instruction = side === 'front' ? locale.doc_front_instruction : locale.doc_back_instruction
71
+ const hint = side === 'front'
72
+ ? 'Hold the camera parallel to the document. Move closer if text is hard to read.'
73
+ : 'Flip the document over and keep it flat inside the frame.'
37
74
 
38
75
  if (!device) {
39
76
  return (
40
- <View style={s.container}>
77
+ <View style={[s.container, { justifyContent: 'center', alignItems: 'center' }]}>
41
78
  <ActivityIndicator color={theme.accent} />
42
79
  </View>
43
80
  )
@@ -45,97 +82,197 @@ export function DocumentStep({ theme, locale, onCapture }: Props) {
45
82
 
46
83
  return (
47
84
  <View style={s.container}>
48
- <Text style={s.title}>{locale.doc_title}</Text>
49
- <Text style={s.subtitle}>{locale.doc_instruction}</Text>
85
+ {/* Heading */}
86
+ <View style={s.heading}>
87
+ <Text style={s.title}>{title}</Text>
88
+ <Text style={s.subtitle}>{instruction}</Text>
89
+ </View>
90
+
91
+ {/* Error */}
92
+ {error ? (
93
+ <View style={s.errorBanner}>
94
+ <Text style={s.errorText}>{error}</Text>
95
+ </View>
96
+ ) : null}
97
+
98
+ {/* Front thumbnail on back step */}
99
+ {side === 'back' && frontUri ? (
100
+ <View style={s.thumbRow}>
101
+ <Image source={{ uri: frontUri }} style={s.thumb} resizeMode="cover" />
102
+ <Text style={s.thumbLabel}>&#10003; Front captured</Text>
103
+ </View>
104
+ ) : null}
105
+
106
+ {/* Camera */}
107
+ <View style={s.viewport}>
108
+ <Camera
109
+ ref={cameraRef}
110
+ style={StyleSheet.absoluteFill}
111
+ device={device}
112
+ isActive
113
+ photo
114
+ />
115
+ {/* Corner bracket guides */}
116
+ <View style={s.frameGuide} pointerEvents="none">
117
+ <View style={[s.corner, s.tl]} />
118
+ <View style={[s.corner, s.tr]} />
119
+ <View style={[s.corner, s.bl]} />
120
+ <View style={[s.corner, s.br]} />
121
+ </View>
122
+ {/* Dim overlay outside frame */}
123
+ <View style={s.dimOverlay} pointerEvents="none" />
124
+ </View>
125
+
126
+ {/* Hint */}
127
+ <View style={s.hint}>
128
+ <Text style={s.hintText}>{hint}</Text>
129
+ </View>
50
130
 
51
- {preview ? (
52
- <>
53
- <Image source={{ uri: preview }} style={s.preview} resizeMode="cover" />
54
- <TouchableOpacity style={s.btnPrimary} onPress={handleConfirm} activeOpacity={0.85}>
55
- <Text style={s.btnText}>{locale.doc_next_btn}</Text>
56
- </TouchableOpacity>
57
- <TouchableOpacity style={s.btnSecondary} onPress={() => setPreview(null)} activeOpacity={0.75}>
58
- <Text style={s.btnSecondaryText}>{locale.doc_retake_btn}</Text>
59
- </TouchableOpacity>
60
- </>
61
- ) : (
62
- <>
63
- <View style={s.viewport}>
64
- <Camera
65
- ref={cameraRef}
66
- style={StyleSheet.absoluteFill}
67
- device={device}
68
- isActive={true}
69
- photo={true}
70
- />
71
- {/* Document frame guide */}
72
- <View style={s.frameGuide} pointerEvents="none" />
73
- </View>
74
- <TouchableOpacity
75
- style={[s.btnPrimary, capturing && s.btnDisabled]}
76
- onPress={handleCapture}
77
- disabled={capturing}
78
- activeOpacity={0.85}
79
- >
80
- {capturing
81
- ? <ActivityIndicator color="#fff" />
82
- : <Text style={s.btnText}>{locale.doc_capture_btn}</Text>
83
- }
84
- </TouchableOpacity>
85
- </>
86
- )}
131
+ {/* Capture button */}
132
+ <TouchableOpacity
133
+ style={[s.btn, capturing && s.btnDisabled]}
134
+ onPress={handleCapture}
135
+ disabled={capturing}
136
+ activeOpacity={0.85}
137
+ >
138
+ {capturing
139
+ ? <ActivityIndicator color={theme.accentInv} />
140
+ : <Text style={s.btnText}>{locale.doc_capture_btn}</Text>
141
+ }
142
+ </TouchableOpacity>
143
+
144
+ {/* Retake front (back step only) */}
145
+ {side === 'back' && onRetakeFront ? (
146
+ <TouchableOpacity style={s.btnGhost} onPress={onRetakeFront} activeOpacity={0.75}>
147
+ <Text style={s.btnGhostText}>{locale.doc_retake_btn} front</Text>
148
+ </TouchableOpacity>
149
+ ) : null}
87
150
  </View>
88
151
  )
89
152
  }
90
153
 
154
+ const CORNER_SIZE = 20
155
+ const CORNER_W = 2
156
+
91
157
  const styles = (t: ResolvedTheme) =>
92
158
  StyleSheet.create({
93
- container: { padding: 24 },
94
- title: { fontSize: 20, fontWeight: '700', color: t.text, marginBottom: 8 },
95
- subtitle: { fontSize: 14, color: t.subtext, lineHeight: 20, marginBottom: 16 },
159
+ container: {
160
+ flex: 1,
161
+ padding: 20,
162
+ gap: 12,
163
+ },
164
+ heading: { gap: 4, alignItems: 'center' },
165
+ title: {
166
+ fontSize: 20,
167
+ fontWeight: '700',
168
+ color: t.text,
169
+ textAlign: 'center',
170
+ letterSpacing: -0.3,
171
+ },
172
+ subtitle: {
173
+ fontSize: 13,
174
+ color: t.subtext,
175
+ lineHeight: 19,
176
+ textAlign: 'center',
177
+ },
178
+ errorBanner: {
179
+ backgroundColor: t.errorBg,
180
+ borderRadius: 8,
181
+ padding: 10,
182
+ },
183
+ errorText: {
184
+ fontSize: 12.5,
185
+ color: t.errorText,
186
+ lineHeight: 17,
187
+ },
188
+ thumbRow: {
189
+ flexDirection: 'row',
190
+ alignItems: 'center',
191
+ gap: 10,
192
+ padding: 8,
193
+ backgroundColor: t.surface,
194
+ borderRadius: 8,
195
+ borderWidth: 1,
196
+ borderColor: t.border,
197
+ },
198
+ thumb: {
199
+ width: 48,
200
+ height: 32,
201
+ borderRadius: 5,
202
+ borderWidth: 1,
203
+ borderColor: t.border,
204
+ },
205
+ thumbLabel: {
206
+ fontSize: 12,
207
+ color: t.subtext,
208
+ },
96
209
  viewport: {
97
- width: '100%',
98
- aspectRatio: 4 / 3,
99
- borderRadius: t.radius,
210
+ flex: 1,
211
+ borderRadius: t.radius - 4,
100
212
  overflow: 'hidden',
101
213
  backgroundColor: '#000',
102
- marginBottom: 16,
103
214
  position: 'relative',
104
215
  },
105
216
  frameGuide: {
106
217
  position: 'absolute',
107
218
  top: '8%',
108
- left: '5%',
109
- right: '5%',
219
+ left: '6%',
220
+ right: '6%',
110
221
  bottom: '8%',
111
- borderWidth: 2,
112
- borderColor: 'rgba(255,255,255,0.7)',
113
- borderRadius: 8,
114
222
  },
115
- preview: {
116
- width: '100%',
117
- aspectRatio: 4 / 3,
118
- borderRadius: t.radius,
119
- marginBottom: 16,
223
+ corner: {
224
+ position: 'absolute',
225
+ width: CORNER_SIZE,
226
+ height: CORNER_SIZE,
227
+ borderColor: 'rgba(255,255,255,0.9)',
228
+ },
229
+ tl: { top: 0, left: 0, borderTopWidth: CORNER_W, borderLeftWidth: CORNER_W, borderTopLeftRadius: 3 },
230
+ tr: { top: 0, right: 0, borderTopWidth: CORNER_W, borderRightWidth: CORNER_W, borderTopRightRadius: 3 },
231
+ bl: { bottom: 0, left: 0, borderBottomWidth: CORNER_W, borderLeftWidth: CORNER_W, borderBottomLeftRadius: 3 },
232
+ br: { bottom: 0, right: 0, borderBottomWidth: CORNER_W, borderRightWidth: CORNER_W, borderBottomRightRadius: 3 },
233
+ dimOverlay: {
234
+ position: 'absolute',
235
+ top: 0, left: 0, right: 0, bottom: 0,
236
+ backgroundColor: 'transparent',
237
+ },
238
+ hint: {
239
+ flexDirection: 'row',
240
+ gap: 7,
241
+ padding: 9,
242
+ backgroundColor: t.surface,
243
+ borderRadius: 8,
120
244
  borderWidth: 1,
121
245
  borderColor: t.border,
122
246
  },
123
- btnPrimary: {
247
+ hintText: {
248
+ flex: 1,
249
+ fontSize: 12,
250
+ color: t.subtext,
251
+ lineHeight: 16,
252
+ },
253
+ btn: {
124
254
  backgroundColor: t.accent,
125
- borderRadius: t.radius,
126
- paddingVertical: 14,
255
+ borderRadius: t.radius - 4,
256
+ paddingVertical: 12,
127
257
  alignItems: 'center',
128
- marginBottom: 10,
129
258
  },
130
- btnSecondary: {
131
- backgroundColor: t.card,
132
- borderRadius: t.radius,
133
- paddingVertical: 14,
259
+ btnDisabled: { opacity: 0.5 },
260
+ btnText: {
261
+ color: t.accentInv,
262
+ fontSize: 14,
263
+ fontWeight: '600',
264
+ },
265
+ btnGhost: {
266
+ borderRadius: t.radius - 4,
267
+ paddingVertical: 11,
134
268
  alignItems: 'center',
135
269
  borderWidth: 1,
136
270
  borderColor: t.border,
271
+ backgroundColor: 'transparent',
272
+ },
273
+ btnGhostText: {
274
+ color: t.subtext,
275
+ fontSize: 13.5,
276
+ fontWeight: '500',
137
277
  },
138
- btnDisabled: { opacity: 0.6 },
139
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
140
- btnSecondaryText: { color: t.text, fontSize: 15, fontWeight: '600' },
141
278
  })
@@ -13,26 +13,22 @@ export function IntroStep({ theme, locale, onStart }: Props) {
13
13
  const s = styles(theme)
14
14
  return (
15
15
  <View style={s.container}>
16
- <View style={s.steps}>
17
- {[
18
- { icon: '🪪', label: 'Document' },
19
- { icon: '🤳', label: 'Selfie' },
20
- { icon: '👁️', label: 'Liveness' },
21
- ].map((item) => (
22
- <View key={item.label} style={s.step}>
23
- <View style={s.stepIcon}>
24
- <Text style={s.stepEmoji}>{item.icon}</Text>
25
- </View>
26
- <Text style={s.stepLabel}>{item.label}</Text>
27
- </View>
28
- ))}
16
+ <View style={s.textBlock}>
17
+ <Text style={s.title}>{locale.intro_title}</Text>
18
+ <Text style={s.subtitle}>{locale.intro_body}</Text>
29
19
  </View>
30
20
 
31
- <Text style={s.title}>{locale.intro_title}</Text>
32
- <Text style={s.body}>{locale.intro_body}</Text>
21
+ {/* Privacy hint */}
22
+ <View style={s.hint}>
23
+ <Text style={s.hintIcon}>&#x1F512;</Text>
24
+ <Text style={s.hintText}>
25
+ Your data is discarded immediately after processing. Nothing is stored.
26
+ </Text>
27
+ </View>
33
28
 
34
29
  <TouchableOpacity style={s.btn} onPress={onStart} activeOpacity={0.85}>
35
30
  <Text style={s.btnText}>{locale.intro_cta}</Text>
31
+ <Text style={s.btnArrow}> →</Text>
36
32
  </TouchableOpacity>
37
33
  </View>
38
34
  )
@@ -40,43 +36,68 @@ export function IntroStep({ theme, locale, onStart }: Props) {
40
36
 
41
37
  const styles = (t: ResolvedTheme) =>
42
38
  StyleSheet.create({
43
- container: { padding: 24 },
44
- steps: {
45
- flexDirection: 'row',
39
+ container: {
40
+ flex: 1,
41
+ padding: 28,
46
42
  justifyContent: 'center',
47
- gap: 20,
48
- marginBottom: 28,
43
+ gap: 16,
49
44
  },
50
- step: { alignItems: 'center', gap: 8 },
51
- stepIcon: {
52
- width: 56,
53
- height: 56,
54
- borderRadius: 14,
55
- backgroundColor: t.card,
56
- borderWidth: 1,
57
- borderColor: t.border,
45
+ textBlock: {
58
46
  alignItems: 'center',
59
- justifyContent: 'center',
47
+ gap: 8,
48
+ marginBottom: 4,
60
49
  },
61
- stepEmoji: { fontSize: 24 },
62
- stepLabel: { fontSize: 12, color: t.subtext },
63
50
  title: {
64
51
  fontSize: 22,
65
52
  fontWeight: '700',
66
53
  color: t.text,
67
- marginBottom: 10,
54
+ textAlign: 'center',
55
+ letterSpacing: -0.4,
68
56
  },
69
- body: {
57
+ subtitle: {
70
58
  fontSize: 14,
71
59
  color: t.subtext,
72
60
  lineHeight: 21,
73
- marginBottom: 28,
61
+ textAlign: 'center',
62
+ },
63
+ hint: {
64
+ flexDirection: 'row',
65
+ alignItems: 'flex-start',
66
+ gap: 8,
67
+ padding: 12,
68
+ backgroundColor: t.surface,
69
+ borderRadius: t.radius - 4,
70
+ borderWidth: 1,
71
+ borderColor: t.border,
72
+ },
73
+ hintIcon: {
74
+ fontSize: 12,
75
+ marginTop: 1,
76
+ },
77
+ hintText: {
78
+ flex: 1,
79
+ fontSize: 12,
80
+ color: t.subtext,
81
+ lineHeight: 17,
74
82
  },
75
83
  btn: {
76
84
  backgroundColor: t.accent,
77
- borderRadius: t.radius,
78
- paddingVertical: 14,
85
+ borderRadius: t.radius - 4,
86
+ paddingVertical: 13,
87
+ paddingHorizontal: 20,
88
+ flexDirection: 'row',
79
89
  alignItems: 'center',
90
+ justifyContent: 'center',
91
+ },
92
+ btnText: {
93
+ color: t.accentInv,
94
+ fontSize: 14,
95
+ fontWeight: '600',
96
+ letterSpacing: -0.2,
97
+ },
98
+ btnArrow: {
99
+ color: t.accentInv,
100
+ fontSize: 14,
101
+ fontWeight: '600',
80
102
  },
81
- btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
82
103
  })