@smileid/web-components 11.4.5 → 11.6.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 (132) hide show
  1. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js +5398 -0
  2. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
  3. package/dist/esm/{Navigation-Bb7MPLE8.js → Navigation-6DH3vF4-.js} +28 -22
  4. package/dist/esm/Navigation-6DH3vF4-.js.map +1 -0
  5. package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
  6. package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
  7. package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js +11470 -0
  8. package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
  9. package/dist/esm/combobox.js +1 -1
  10. package/dist/esm/document.js +1 -1
  11. package/dist/esm/end-user-consent.js +713 -2
  12. package/dist/esm/end-user-consent.js.map +1 -1
  13. package/dist/esm/index-BqyuTk9f.js +1366 -0
  14. package/dist/esm/{index-C4RTMbgw.js.map → index-BqyuTk9f.js.map} +1 -1
  15. package/dist/esm/localisation.js +1 -1
  16. package/dist/esm/main.js +14 -14
  17. package/dist/esm/navigation.js +1 -1
  18. package/dist/esm/package-CjZI-cNQ.js +2540 -0
  19. package/dist/esm/package-CjZI-cNQ.js.map +1 -0
  20. package/dist/esm/selfie.js +1 -1
  21. package/dist/esm/smart-camera-web.js +81 -37
  22. package/dist/esm/smart-camera-web.js.map +1 -1
  23. package/dist/esm/totp-consent.js +731 -2
  24. package/dist/esm/totp-consent.js.map +1 -1
  25. package/dist/esm/validate.js +31 -0
  26. package/dist/esm/validate.js.map +1 -0
  27. package/dist/smart-camera-web.js +1513 -383
  28. package/dist/smart-camera-web.js.map +1 -1
  29. package/dist/types/main.d.ts +18 -1
  30. package/dist/types/validate.d.ts +21 -0
  31. package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
  32. package/lib/components/document/src/assets/lottie.d.ts +12 -0
  33. package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
  34. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
  35. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
  36. package/lib/components/document/src/document-auto-capture/README.md +73 -0
  37. package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
  38. package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
  39. package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
  40. package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
  41. package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
  42. package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
  43. package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
  44. package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
  45. package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
  46. package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
  47. package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
  48. package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
  49. package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
  50. package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
  51. package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
  52. package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
  53. package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
  54. package/lib/components/document/src/document-auto-capture/index.ts +4 -0
  55. package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
  56. package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
  57. package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
  58. package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
  59. package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
  60. package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
  61. package/lib/components/document/src/document-capture-submission/index.js +3 -0
  62. package/lib/components/navigation/src/Navigation.js +27 -8
  63. package/lib/components/selfie/README.md +13 -0
  64. package/lib/components/selfie/src/SelfieCaptureScreens.js +56 -8
  65. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
  66. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
  67. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
  68. package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
  69. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
  70. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
  71. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
  72. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
  73. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
  74. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
  75. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
  76. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
  77. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
  78. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
  79. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
  80. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
  81. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
  82. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
  83. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
  84. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
  85. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
  86. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
  87. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
  88. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
  89. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
  90. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
  91. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
  92. package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
  93. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
  94. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
  95. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
  96. package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
  97. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
  98. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
  99. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
  100. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
  101. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
  102. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
  103. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
  104. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +24 -1
  105. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
  106. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
  107. package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
  108. package/lib/components/signature-pad/package.json +1 -1
  109. package/lib/components/smart-camera-web/src/README.md +11 -0
  110. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +89 -8
  111. package/lib/components/totp-consent/src/TotpConsent.js +1 -1
  112. package/lib/domain/localisation/index.js +2 -2
  113. package/package.json +9 -5
  114. package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js +0 -4147
  115. package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js.map +0 -1
  116. package/dist/esm/EndUserConsent-uHfA3txP.js +0 -717
  117. package/dist/esm/EndUserConsent-uHfA3txP.js.map +0 -1
  118. package/dist/esm/Navigation-Bb7MPLE8.js.map +0 -1
  119. package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
  120. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js +0 -7651
  121. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +0 -1
  122. package/dist/esm/TotpConsent-Depzg0ti.js +0 -734
  123. package/dist/esm/TotpConsent-Depzg0ti.js.map +0 -1
  124. package/dist/esm/index-C4RTMbgw.js +0 -1360
  125. package/dist/esm/package-D6YrpMcO.js +0 -565
  126. package/dist/esm/package-D6YrpMcO.js.map +0 -1
  127. package/dist/esm/styles-BTEClL7R.js +0 -419
  128. package/dist/esm/styles-BTEClL7R.js.map +0 -1
  129. /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
  130. /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
  131. /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
  132. /package/lib/components/document/src/assets/lottie/{taking photo of passport 2.lottie → passport.lottie} +0 -0
@@ -0,0 +1,684 @@
1
+ import { useRef, useEffect, useState } from 'preact/hooks';
2
+ import { signal } from '@preact/signals';
3
+ import register from 'preact-custom-element';
4
+ import type { FunctionComponent } from 'preact';
5
+
6
+ import { getBoolProp } from '../../../../utils/props';
7
+ import { t } from '../../../../domain/localisation';
8
+ import { useFaceCapture, useCamera } from './hooks';
9
+ import { getMediapipeInstance } from './utils/mediapipeManager';
10
+ import { CameraPreview } from './components/CameraPreview';
11
+ import { AlertDisplay } from './components/AlertDisplay';
12
+ import { CaptureControls } from './components/CaptureControls';
13
+ import { ActiveLivenessOverlay } from './components/ActiveLivenessOverlay';
14
+ import { CaptureGuidelines } from './components/CaptureGuidelines';
15
+ import { SubmissionView } from './components/SubmissionView';
16
+ import { BackNavigation } from './components/BackNavigation';
17
+
18
+ import '../../../navigation/src';
19
+ import '../../../attribution/PoweredBySmileId';
20
+ // Side-effect imports: register the standalone consent and submission
21
+ // custom elements so partners can mount them independently of ESS
22
+ // (`<enhanced-smart-selfie-consent>` / `<enhanced-smart-selfie-submission>`).
23
+ import './EnhancedSmartSelfieConsent';
24
+ import './EnhancedSmartSelfieSubmission';
25
+
26
+ // ESS owns the in-flow capture experience: a pre-capture guidelines screen
27
+ // (the "Capture Guidelines" tiles + active-liveness hero animation), the
28
+ // active-liveness capture itself, and the post-capture review screen. Consent
29
+ // and post-confirm submission UI are deliberately not part of this element —
30
+ // they're shipped as separate custom elements (`<enhanced-smart-selfie-
31
+ // consent>`, `<enhanced-smart-selfie-submission>`) that callers mount
32
+ // around ESS as their product flow requires.
33
+ type View = 'guidelines' | 'capture' | 'review';
34
+
35
+ interface Props {
36
+ interval?: number;
37
+ duration?: number;
38
+ 'theme-color'?: string;
39
+ 'show-navigation'?: string | boolean;
40
+ 'allow-agent-mode'?: string | boolean;
41
+ 'show-agent-mode-for-tests'?: string | boolean;
42
+ 'hide-attribution'?: string | boolean;
43
+ 'disable-image-tests'?: string | boolean;
44
+ /** When true, skip the guidelines screen and go straight to capture. */
45
+ 'hide-instructions'?: string | boolean;
46
+ /**
47
+ * Render the back button on the guidelines screen. Off by default because
48
+ * guidelines is the first ESS view, so there is nothing within ESS to
49
+ * navigate back to. Hosts that mount ESS after their own prior screen
50
+ * (e.g. a consent step in a KYC / DocV flow) opt in by setting this so
51
+ * the back button surfaces and `selfie-capture.cancelled` fires for the
52
+ * host to handle navigation back to the previous view.
53
+ */
54
+ 'show-back-on-guidelines'?: string | boolean;
55
+ }
56
+
57
+ const EnhancedSmartSelfieCapture: FunctionComponent<Props> = ({
58
+ interval = 350,
59
+ duration = 2800,
60
+ 'theme-color': themeColor = '#001096',
61
+ 'show-navigation': showNavigationProp = false,
62
+ 'allow-agent-mode': allowAgentModeProp = false,
63
+ 'show-agent-mode-for-tests': showAgentModeForTestsProp = false,
64
+ 'hide-attribution': hideAttributionProp = false,
65
+ 'hide-instructions': hideInstructionsProp = false,
66
+ 'show-back-on-guidelines': showBackOnGuidelinesProp = false,
67
+ }) => {
68
+ const canvasRef = useRef<HTMLCanvasElement>(null);
69
+
70
+ const showNavigation = getBoolProp(showNavigationProp);
71
+ const allowAgentMode = getBoolProp(allowAgentModeProp);
72
+ const showAgentModeForTests = getBoolProp(showAgentModeForTestsProp);
73
+ const hideAttribution = getBoolProp(hideAttributionProp);
74
+ const hideInstructions = getBoolProp(hideInstructionsProp);
75
+ const showBackOnGuidelines = getBoolProp(showBackOnGuidelinesProp);
76
+
77
+ // This component is always strict-mode (Active Liveness).
78
+ const useStrictMode = true;
79
+
80
+ const smileCooldown = 300;
81
+ const smileThreshold = 0.25;
82
+ const mouthOpenThreshold = 0.05;
83
+ // Slightly tighter range than the legacy SmartSelfie defaults so the
84
+ // "too far / too close" edge cases actually trigger when the face fills
85
+ // significantly less / more of the oval than expected. The band is wider
86
+ // than 0.34–0.55 because hysteresis combined with the 0.34 floor caused
87
+ // typical webcam framing to lock into a "too far" state, masking every
88
+ // other check downstream of it.
89
+ const minFaceSize = 0.35;
90
+ const maxFaceSize = 0.62;
91
+
92
+ // Agent mode is opt-in: the partner enables the toggle via `allow-agent-mode`,
93
+ // but the user-facing camera is always the default. The toggle button lets
94
+ // an operator switch to the rear (environment) camera mid-session.
95
+ const initialFacingMode = 'user';
96
+ const camera = useCamera(initialFacingMode);
97
+
98
+ // Three-view flow: guidelines → capture → review. `hide-instructions`
99
+ // skips the guidelines screen and jumps straight to capture. On confirm,
100
+ // ESS emits `selfie-capture.publish` and hands off to the caller — the
101
+ // screens router (`<selfie-capture-screens>`) or, in the SmartSelfie Auth
102
+ // case, the host product script — which decides what to render next.
103
+ const initialView: View = hideInstructions ? 'capture' : 'guidelines';
104
+ const viewSignal = useRef(signal<View>(initialView)).current;
105
+ const pendingPayload = useRef<any>(null);
106
+
107
+ const faceCapture = useFaceCapture({
108
+ videoRef: camera.videoRef,
109
+ canvasRef,
110
+ interval,
111
+ duration,
112
+ smileThreshold,
113
+ mouthOpenThreshold,
114
+ minFaceSize,
115
+ maxFaceSize,
116
+ smileCooldown,
117
+ getFacingMode: () => camera.facingMode,
118
+ useStrictMode,
119
+ onCaptureComplete: (detail) => {
120
+ // Stash the payload and show the review screen. The user can retake
121
+ // or confirm; only on confirm do we emit `selfie-capture.publish`.
122
+ //
123
+ // Forced-failure completions (e.g. the 120s active-liveness
124
+ // inactivity timeout from the hosted-web page) publish immediately
125
+ // with `detail.forceFailureReason` set so the caller can route to
126
+ // its own failure UI without going through review.
127
+ pendingPayload.current = detail;
128
+ if (detail.forceFailureReason) {
129
+ // Tear down the camera + detection loop explicitly. We're skipping
130
+ // review, so the view stays on 'capture' and the mount effect's
131
+ // cleanup never runs on its own — without this, MediaPipe and the
132
+ // camera stream would keep running silently while the host shows
133
+ // its post-publish UI.
134
+ faceCapture.stopDetectionLoop();
135
+ camera.stopCamera();
136
+ faceCapture.cleanup();
137
+ // Dedicated forced-failure signal for the host. The intermediate
138
+ // `<selfie-capture-screens>` / `<smart-camera-web>` chain only
139
+ // forwards `images` in their re-dispatched `*.publish` events, so
140
+ // `forceFailureReason` would otherwise be invisible at the host
141
+ // layer. We fire this synchronously *before* `selfie-capture.publish`
142
+ // so any host listener can flip its "this is a forced failure" flag
143
+ // in time for the publish handler that follows.
144
+ window.dispatchEvent(
145
+ new CustomEvent('enhanced-smartselfie.force-fail-published', {
146
+ detail: { reason: detail.forceFailureReason },
147
+ }),
148
+ );
149
+ window.dispatchEvent(
150
+ new CustomEvent('selfie-capture.publish', { detail }),
151
+ );
152
+ return;
153
+ }
154
+ viewSignal.value = 'review';
155
+ },
156
+ });
157
+
158
+ useEffect(() => {
159
+ if (viewSignal.value !== 'capture') return undefined;
160
+
161
+ const initializeCamera = async () => {
162
+ await camera.startCamera(initialFacingMode, (cameraName) => {
163
+ const smartCameraWeb = document.querySelector('smart-camera-web');
164
+ smartCameraWeb?.dispatchEvent(
165
+ new CustomEvent('metadata.camera-name', {
166
+ detail: { cameraName },
167
+ }),
168
+ );
169
+ });
170
+ await camera.checkAgentSupport();
171
+ await faceCapture.initializeFaceLandmarker();
172
+
173
+ setTimeout(() => {
174
+ faceCapture.setupCanvas();
175
+ faceCapture.startDetectionLoop();
176
+ }, 500);
177
+ };
178
+
179
+ camera.registerCameraSwitchCallback(() => {
180
+ try {
181
+ faceCapture.resetFaceDetectionState();
182
+ faceCapture.setupCanvas();
183
+ faceCapture.stopDetectionLoop();
184
+ faceCapture.startDetectionLoop();
185
+ } catch (error) {
186
+ console.error('Error during camera switch callback:', error);
187
+ }
188
+ });
189
+
190
+ initializeCamera();
191
+
192
+ return () => {
193
+ faceCapture.stopDetectionLoop();
194
+ camera.stopCamera();
195
+ faceCapture.cleanup();
196
+ };
197
+ }, [viewSignal.value]);
198
+
199
+ // Reset scroll-to-top whenever the active view changes. Without this the
200
+ // host page (which now drives scrolling, since each ESS view uses natural
201
+ // document flow) would land on whatever scroll offset the previous view
202
+ // left behind \u2014 e.g. switching from a long Consent page to Instructions
203
+ // would render Instructions already scrolled to the bottom.
204
+ useEffect(() => {
205
+ try {
206
+ window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
207
+ } catch {
208
+ window.scrollTo(0, 0);
209
+ }
210
+ }, [viewSignal.value]);
211
+
212
+ useEffect(() => {
213
+ if (faceCapture.hasFinishedCapture.value) {
214
+ const smartCameraWeb = document.querySelector('smart-camera-web');
215
+ smartCameraWeb?.dispatchEvent(
216
+ new CustomEvent('metadata.selfie-capture-end'),
217
+ );
218
+ }
219
+ }, [faceCapture.hasFinishedCapture.value]);
220
+
221
+ // Brief "hold still" period at the start of capture: the reference selfie
222
+ // is taken in this window and the active-liveness animation/prompts only
223
+ // appear after it elapses.
224
+ const HOLD_STILL_MS = 1800;
225
+ const [holdStillElapsed, setHoldStillElapsed] = useState(false);
226
+ // Tracks whether the Mediapipe model itself has finished downloading. We
227
+ // pre-warm it as soon as ESS mounts so by the time the user finishes
228
+ // reading the guidelines the heavy network work is already done and
229
+ // Continue can be enabled immediately.
230
+ const [isMediapipeReady, setIsMediapipeReady] = useState(false);
231
+ useEffect(() => {
232
+ let cancelled = false;
233
+ getMediapipeInstance()
234
+ .then(() => {
235
+ if (!cancelled) setIsMediapipeReady(true);
236
+ })
237
+ .catch(() => {
238
+ // Swallow — the capture screen will retry and surface any error there.
239
+ });
240
+ return () => {
241
+ cancelled = true;
242
+ };
243
+ }, []);
244
+ useEffect(() => {
245
+ if (!faceCapture.isCapturing.value) {
246
+ setHoldStillElapsed(false);
247
+ return undefined;
248
+ }
249
+ setHoldStillElapsed(false);
250
+ const id = window.setTimeout(
251
+ () => setHoldStillElapsed(true),
252
+ HOLD_STILL_MS,
253
+ );
254
+ return () => window.clearTimeout(id);
255
+ }, [faceCapture.isCapturing.value]);
256
+
257
+ // Pre-capture fallback: enable Start Capture after 5s even if the user
258
+ // hasn't centred their head yet. Quality checks (lighting / blur /
259
+ // distance) only run during active capture, so we don't want partners to
260
+ // get stuck on a permanently-disabled button.
261
+ const PRE_CAPTURE_FALLBACK_MS = 5000;
262
+ const [preCaptureFallbackElapsed, setPreCaptureFallbackElapsed] =
263
+ useState(false);
264
+ useEffect(() => {
265
+ if (viewSignal.value !== 'capture') return undefined;
266
+ if (faceCapture.isCapturing.value) return undefined;
267
+ if (faceCapture.hasFinishedCapture.value) return undefined;
268
+ setPreCaptureFallbackElapsed(false);
269
+ const id = window.setTimeout(
270
+ () => setPreCaptureFallbackElapsed(true),
271
+ PRE_CAPTURE_FALLBACK_MS,
272
+ );
273
+ return () => window.clearTimeout(id);
274
+ }, [
275
+ viewSignal.value,
276
+ faceCapture.isCapturing.value,
277
+ faceCapture.hasFinishedCapture.value,
278
+ ]);
279
+
280
+ // Track device orientation. Active-liveness expects the user to hold the
281
+ // device upright — landscape framing puts the face sideways inside the
282
+ // oval, breaks the bounds/pose checks, and produces unusable selfies.
283
+ // We detect both rotated screens (matchMedia) and rotated mobile devices
284
+ // that report orientation through the Screen Orientation API.
285
+ const [isLandscape, setIsLandscape] = useState(false);
286
+ useEffect(() => {
287
+ if (typeof window === 'undefined' || !window.matchMedia) return undefined;
288
+
289
+ // Only enforce the orientation edge case on touch / mobile-class devices.
290
+ // Desktop/laptop browsers can be resized into a "landscape <" wrapper
291
+ // ratio without the camera or user actually being sideways, and there's
292
+ // no rotate gesture available to recover from the prompt.
293
+ const isMobileDevice =
294
+ // matchMedia hover/pointer hints — most reliable cross-browser way to
295
+ // tell apart a touch-only device from a mouse-driven one.
296
+ window.matchMedia('(hover: none) and (pointer: coarse)').matches ||
297
+ // Touch capability fallback for browsers that don't expose the hover
298
+ // media query (older Android WebView, embedded browsers).
299
+ (typeof navigator !== 'undefined' &&
300
+ ((navigator as any).maxTouchPoints > 0 ||
301
+ /Android|iPhone|iPad|iPod|Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(
302
+ navigator.userAgent || '',
303
+ )));
304
+ if (!isMobileDevice) return undefined;
305
+
306
+ const mql = window.matchMedia('(orientation: landscape)');
307
+ const update = () => setIsLandscape(mql.matches);
308
+ update();
309
+ // Older Safari only supports addListener/removeListener.
310
+ if (mql.addEventListener) mql.addEventListener('change', update);
311
+ else mql.addListener(update);
312
+ return () => {
313
+ if (mql.removeEventListener) mql.removeEventListener('change', update);
314
+ else mql.removeListener(update);
315
+ };
316
+ }, []);
317
+
318
+ // If the device flips to landscape mid-capture, pause the capture interval
319
+ // immediately so we don't accumulate liveness frames while the user's face
320
+ // is sideways inside the oval. Resume once the device returns to portrait —
321
+ // the capture sequence picks up from where it left off and continues
322
+ // forward without forcing the user to restart.
323
+ useEffect(() => {
324
+ if (!faceCapture.isCapturing.value) return;
325
+ if (isLandscape) {
326
+ faceCapture.pauseCapture();
327
+ } else if (faceCapture.isPaused.value) {
328
+ faceCapture.resumeCapture();
329
+ }
330
+ }, [isLandscape, faceCapture.isCapturing.value]);
331
+
332
+ // Host-driven forced-failure path (e.g. the hosted-web 120s active-liveness
333
+ // inactivity timer). The host dispatches `enhanced-smartselfie.force-fail`
334
+ // with a `{ reason }` detail; the hook packages whatever frames have been
335
+ // captured so far and fires `onCaptureComplete` with `forceFailureReason`
336
+ // set, which publishes immediately so the caller can surface its own
337
+ // failure UI.
338
+ useEffect(() => {
339
+ const handler = (e: Event) => {
340
+ const ce = e as CustomEvent<{ reason?: string }>;
341
+ const reason = ce.detail?.reason ?? 'active_liveness_timed_out';
342
+ // Only react while the user is actively in the capture flow; ignore
343
+ // the event if we've already moved on to review/submitting/etc.
344
+ if (viewSignal.value !== 'capture') return;
345
+ faceCapture.forceFailCapture(reason);
346
+ };
347
+ window.addEventListener('enhanced-smartselfie.force-fail', handler);
348
+ return () => {
349
+ window.removeEventListener('enhanced-smartselfie.force-fail', handler);
350
+ };
351
+ }, []);
352
+
353
+ const handleConfirm = () => {
354
+ const payload = pendingPayload.current;
355
+ if (payload) {
356
+ // Publish and stay on the review screen. The caller — the screens
357
+ // router for KYC/DV/EDV flows, or the SmartSelfie Auth host page —
358
+ // decides what to render next (next form, submitting card, etc.).
359
+ window.dispatchEvent(
360
+ new CustomEvent('selfie-capture.publish', { detail: payload }),
361
+ );
362
+ }
363
+ };
364
+
365
+ const handleRetake = () => {
366
+ pendingPayload.current = null;
367
+ faceCapture.hasFinishedCapture.value = false;
368
+ faceCapture.capturesTaken.value = 0;
369
+ viewSignal.value = 'capture';
370
+ };
371
+
372
+ // Back-button navigation: review → retake; capture → guidelines (or
373
+ // cancel when guidelines are hidden); guidelines → cancel. "Cancel"
374
+ // surfaces `selfie-capture.cancelled` so the caller can decide what to do.
375
+ const handleBack = () => {
376
+ const current = viewSignal.value;
377
+ if (current === 'review') {
378
+ handleRetake();
379
+ return;
380
+ }
381
+ if (current === 'capture') {
382
+ // Stop the camera/detection loop before leaving the capture view so
383
+ // we don't leak resources while sitting on the guidelines screen.
384
+ faceCapture.stopDetectionLoop();
385
+ camera.stopCamera();
386
+ faceCapture.resetFaceDetectionState();
387
+ // Wipe the in-flight capture session so re-entering capture starts
388
+ // fresh (new pose sequence, prompt 1, empty image buffer).
389
+ faceCapture.stopCapture();
390
+ faceCapture.capturedImages.value = [];
391
+ faceCapture.referencePhoto.value = '';
392
+ faceCapture.poseSequence.value = [];
393
+ faceCapture.currentPoseIndex.value = 0;
394
+ faceCapture.hasFinishedCapture.value = false;
395
+ faceCapture.capturesTaken.value = 0;
396
+ if (!hideInstructions) {
397
+ viewSignal.value = 'guidelines';
398
+ return;
399
+ }
400
+ faceCapture.handleCancel();
401
+ return;
402
+ }
403
+ // Guidelines or anything else: cancel.
404
+ faceCapture.handleCancel();
405
+ };
406
+
407
+ if (viewSignal.value === 'guidelines') {
408
+ return (
409
+ <CaptureGuidelines
410
+ themeColor={themeColor}
411
+ hideAttribution={hideAttribution}
412
+ isReady={isMediapipeReady}
413
+ onContinue={() => {
414
+ viewSignal.value = 'capture';
415
+ }}
416
+ onBack={showNavigation && showBackOnGuidelines ? handleBack : undefined}
417
+ />
418
+ );
419
+ }
420
+
421
+ if (viewSignal.value === 'review') {
422
+ const detail = pendingPayload.current;
423
+ const src = detail?.referenceImage || '';
424
+ const mirror = detail?.facingMode === 'user';
425
+ return (
426
+ <SubmissionView
427
+ imageSrc={src}
428
+ mirror={mirror}
429
+ themeColor={themeColor}
430
+ hideAttribution={hideAttribution}
431
+ mode="review"
432
+ onConfirm={handleConfirm}
433
+ onRetake={handleRetake}
434
+ onBack={showNavigation ? handleBack : undefined}
435
+ />
436
+ );
437
+ }
438
+
439
+ // Centralised quality-check evaluation: pick the dominant problem and
440
+ // derive both the prompt copy and which side of the oval to colour red.
441
+ // Pre-capture (idle) only nudges the user to centre their head — every
442
+ // other quality check (lighting / blur / proximity) is deferred until
443
+ // active capture begins. During capture, framing/centering is no longer
444
+ // re-evaluated since the head is already locked in.
445
+ const isIdle =
446
+ !faceCapture.isCapturing.value && !faceCapture.hasFinishedCapture.value;
447
+ const isHoldingStill = faceCapture.isCapturing.value && !holdStillElapsed;
448
+
449
+ const isFaceCentered =
450
+ faceCapture.faceDetected.value &&
451
+ faceCapture.faceInBounds.value &&
452
+ !faceCapture.faceClippingOval.value;
453
+
454
+ // Default to the pre-capture instruction. We deliberately ignore the
455
+ // hook's transient alertTitle here so the prompt never blanks out as
456
+ // detection signals fluctuate — it stays put until capture begins.
457
+ let alertTitle = isIdle
458
+ ? t('selfie.ess.alert.centerFace')
459
+ : faceCapture.alertTitle.value;
460
+ let errorSide: 'top' | 'right' | 'bottom' | 'left' | 'all' | null = null;
461
+ // Whether any active-capture quality check is currently failing. Used to
462
+ // suppress the active-liveness pose animation so the user isn't shown a
463
+ // "turn your head" cue while we still need them to fix lighting/blur/etc.
464
+ let captureCheckFailing = false;
465
+
466
+ if (isLandscape) {
467
+ alertTitle = '';
468
+ errorSide = 'all';
469
+ captureCheckFailing = true;
470
+ } else if (isIdle) {
471
+ // Pre-capture: keep the static "Centre your face within the oval frame"
472
+ // instruction. No directional / lighting / blur / distance prompts run
473
+ // until the user taps Start Capture — quality checks are deferred to
474
+ // the active-capture phase.
475
+ } else if (isHoldingStill) {
476
+ alertTitle = t('selfie.ess.alert.holdStill');
477
+ } else if (faceCapture.isCapturing.value) {
478
+ // Active capture: run quality checks. Centering is no longer evaluated
479
+ // here — once capture has started, head turns are expected.
480
+ if (faceCapture.isTooDark.value) {
481
+ alertTitle = t('selfie.ess.alert.tooDark');
482
+ errorSide = 'all';
483
+ captureCheckFailing = true;
484
+ } else if (faceCapture.isTooBlurry.value) {
485
+ errorSide = 'all';
486
+ captureCheckFailing = true;
487
+ } else if (faceCapture.faceProximity.value === 'too-close') {
488
+ alertTitle = t('selfie.ess.alert.tooClose');
489
+ errorSide = 'all';
490
+ captureCheckFailing = true;
491
+ } else if (faceCapture.faceProximity.value === 'too-far') {
492
+ alertTitle = t('selfie.ess.alert.tooFar');
493
+ errorSide = 'all';
494
+ captureCheckFailing = true;
495
+ } else if (faceCapture.faceOffsetDirection.value) {
496
+ // Directional nudges: face is detected but offset from the oval
497
+ // centre by more than the hook's small threshold. Surfaced during
498
+ // capture so the user re-frames before we evaluate pose prompts.
499
+ const dir = faceCapture.faceOffsetDirection.value;
500
+ if (dir === 'top') alertTitle = t('selfie.ess.alert.moveDeviceUp');
501
+ else if (dir === 'bottom')
502
+ alertTitle = t('selfie.ess.alert.moveDeviceDown');
503
+ else if (dir === 'left')
504
+ alertTitle = t('selfie.ess.alert.moveDeviceLeft');
505
+ else if (dir === 'right')
506
+ alertTitle = t('selfie.ess.alert.moveDeviceRight');
507
+ errorSide = dir;
508
+ captureCheckFailing = true;
509
+ } else {
510
+ // Active-liveness pose prompts: override the localised hook copy with
511
+ // the design-spec wording.
512
+ const pose =
513
+ faceCapture.poseSequence.value[faceCapture.currentPoseIndex.value];
514
+ if (pose === 'left') alertTitle = t('selfie.ess.alert.turnHeadLeft');
515
+ else if (pose === 'right')
516
+ alertTitle = t('selfie.ess.alert.turnHeadRight');
517
+ else if (pose === 'up') alertTitle = t('selfie.ess.alert.tiltHeadUp');
518
+ }
519
+ }
520
+
521
+ return (
522
+ <div className="smartselfie-capture">
523
+ {showNavigation && (
524
+ <BackNavigation onBack={handleBack} themeColor={themeColor} />
525
+ )}
526
+
527
+ <CameraPreview
528
+ videoRef={camera.videoRef}
529
+ canvasRef={canvasRef}
530
+ facingMode={camera.facingMode}
531
+ themeColor={themeColor}
532
+ errorSide={errorSide}
533
+ overlay={(() => {
534
+ if (isLandscape) {
535
+ return (
536
+ <ActiveLivenessOverlay
537
+ pose={null}
538
+ currentPose={null}
539
+ isTooDark={false}
540
+ isLandscape
541
+ />
542
+ );
543
+ }
544
+ if (faceCapture.isTooDark.value) {
545
+ // Show the too-dark animation as soon as the scene is too dark,
546
+ // even before active capture starts, so the user gets visual
547
+ // guidance matching the alert text.
548
+ return (
549
+ <ActiveLivenessOverlay pose={null} currentPose={null} isTooDark />
550
+ );
551
+ }
552
+ if (
553
+ faceCapture.isCapturing.value &&
554
+ holdStillElapsed &&
555
+ !captureCheckFailing
556
+ ) {
557
+ return (
558
+ <ActiveLivenessOverlay
559
+ pose={
560
+ faceCapture.poseSequence.value[
561
+ faceCapture.currentPoseIndex.value
562
+ ] ?? null
563
+ }
564
+ currentPose={faceCapture.currentPose.value}
565
+ isTooDark={faceCapture.isTooDark.value}
566
+ />
567
+ );
568
+ }
569
+ return null;
570
+ })()}
571
+ />
572
+
573
+ {/* Alert text + controls + attribution sit naturally below the
574
+ camera preview, matching the SmartSelfieCapture layout. */}
575
+ <AlertDisplay alertTitle={alertTitle} themeColor={themeColor} />
576
+
577
+ {!faceCapture.isCapturing.value &&
578
+ !faceCapture.hasFinishedCapture.value && (
579
+ <CaptureControls
580
+ isCapturing={faceCapture.isCapturing.value}
581
+ hasFinishedCapture={faceCapture.hasFinishedCapture.value}
582
+ // Pre-capture readiness: face must be centered, OR the 5-second
583
+ // fallback has elapsed. Quality checks no longer gate the Start
584
+ // Capture button — they only run after capture begins.
585
+ isReadyToCapture={
586
+ (isFaceCentered || preCaptureFallbackElapsed) && !isLandscape
587
+ }
588
+ // ESS strict-mode: never bypass the readiness check. The legacy
589
+ // fallback timer enables the button after 10s even when the
590
+ // face isn't centered, but we want capture to only start once
591
+ // the user is properly framed inside the oval.
592
+ captureButtonFallbackEnabled={false}
593
+ allowAgentMode={allowAgentMode}
594
+ agentSupported={camera.agentSupported}
595
+ showAgentModeForTests={showAgentModeForTests}
596
+ facingMode={camera.facingMode}
597
+ themeColor={themeColor}
598
+ onStartCapture={faceCapture.startCapture}
599
+ onSwitchCamera={camera.switchCamera}
600
+ />
601
+ )}
602
+
603
+ {!hideAttribution && (
604
+ // @ts-expect-error preact-custom-element types
605
+ <powered-by-smile-id />
606
+ )}
607
+
608
+ <style>{`
609
+ * { box-sizing: border-box; }
610
+ :host { display: block; height: 100%; }
611
+ .smartselfie-capture {
612
+ position: relative;
613
+ display: flex;
614
+ flex-direction: column;
615
+ height: 100%;
616
+ padding: 6.5rem 1rem 0.5rem;
617
+ font-family: "DM Sans", system-ui, sans-serif;
618
+ background: #F5F7FA;
619
+ overflow: hidden;
620
+ }
621
+ .smartselfie-capture > .ess-back-navigation {
622
+ position: absolute;
623
+ top: 1.5rem;
624
+ left: 1rem;
625
+ z-index: 2;
626
+ }
627
+ .smartselfie-capture > powered-by-smile-id {
628
+ display: block;
629
+ width: 100%;
630
+ margin-top: clamp(0.75rem, 2dvh, 1.25rem);
631
+ padding-top: clamp(0.4rem, 1dvh, 0.6rem);
632
+ background: #F5F7FA;
633
+ flex-shrink: 0;
634
+ }
635
+ button {
636
+ padding: 10px 20px;
637
+ background: ${themeColor || '#001096'};
638
+ color: white;
639
+ border: none;
640
+ border-radius: 4px;
641
+ cursor: pointer;
642
+ font-size: 16px;
643
+ }
644
+ button:disabled { background: #ccc; cursor: not-allowed; }
645
+ button.btn-primary {
646
+ background-color: ${themeColor || '#001096'};
647
+ border-radius: 2.5rem;
648
+ color: white;
649
+ border: none;
650
+ height: 3.125rem;
651
+ padding: 0.75rem 1.5rem;
652
+ font-size: 1.125rem;
653
+ font-weight: 600;
654
+ font-family: inherit;
655
+ cursor: pointer;
656
+ }
657
+ button.btn-primary:hover { background-color: #2d2b2a; }
658
+ button.btn-primary:disabled { background-color: #666; cursor: not-allowed; }
659
+ `}</style>
660
+ </div>
661
+ );
662
+ };
663
+
664
+ if (!customElements.get('enhanced-smartselfie-capture')) {
665
+ register(
666
+ EnhancedSmartSelfieCapture,
667
+ 'enhanced-smartselfie-capture',
668
+ [
669
+ 'interval',
670
+ 'duration',
671
+ 'theme-color',
672
+ 'show-navigation',
673
+ 'allow-agent-mode',
674
+ 'show-agent-mode-for-tests',
675
+ 'hide-attribution',
676
+ 'disable-image-tests',
677
+ 'hide-instructions',
678
+ 'show-back-on-guidelines',
679
+ ],
680
+ { shadow: true },
681
+ );
682
+ }
683
+
684
+ export default EnhancedSmartSelfieCapture;