@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,1458 @@
1
+ import { useState, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
2
+ import register from 'preact-custom-element';
3
+ import type { FunctionComponent } from 'preact';
4
+
5
+ import { useCamera } from './hooks/useCamera';
6
+ import { useCardDetection, COMPLIANCE_STATES } from './hooks/useCardDetection';
7
+ import { Overlay } from './components/Overlay';
8
+ import { CaptureButton } from './components/CaptureButton';
9
+ import { TuningPanel } from './components/TuningPanel';
10
+ import { ensureOpenCv } from './utils/opencvLoader';
11
+ import { isDebugEnabled } from './utils/debug';
12
+ import { theme } from './theme';
13
+ import { translate } from '../../../../domain/localisation';
14
+
15
+ import '../../../navigation/src';
16
+
17
+ import { getBoolProp } from '../../../../utils/props';
18
+ import { JPEG_QUALITY } from '../../../../domain/constants/src/Constants';
19
+
20
+ interface Props {
21
+ 'document-type'?: string;
22
+ 'auto-capture'?: 'autoCapture' | 'autoCaptureOnly' | 'manualCaptureOnly';
23
+ 'auto-capture-timeout'?: string | number;
24
+ 'side-of-id'?: 'Front' | 'Back' | string;
25
+ 'show-navigation'?: string | boolean;
26
+ 'allow-gallery-upload'?: string | boolean;
27
+ 'document-capture-modes'?: string;
28
+ 'sync-roi-to-guide'?: string | boolean;
29
+ 'theme-color'?: string;
30
+ title?: string;
31
+ }
32
+
33
+ type CaptureMode = 'autoCapture' | 'autoCaptureOnly' | 'manualCaptureOnly';
34
+ const CAPTURE_MODES: CaptureMode[] = [
35
+ 'autoCapture',
36
+ 'autoCaptureOnly',
37
+ 'manualCaptureOnly',
38
+ ];
39
+
40
+ // Settings shared by both device profiles. These are device-independent —
41
+ // shape/colour gates and crop geometry behave the same on a phone and a webcam,
42
+ // so they live in one place to stop the two profiles from drifting apart.
43
+ const SHARED_DEFAULTS = {
44
+ // Adaptive contour-Canny sensitivity. The high threshold is
45
+ // mean + autoCannySigma·stddev of the frame's gradient magnitude, clamped to
46
+ // [60, 150]. Lower = detect fainter borders (better on plain backgrounds),
47
+ // higher = stricter (fewer false edges on busy backgrounds).
48
+ autoCannySigma: 1.0,
49
+ chromaCannyLow: 15,
50
+ chromaCannyHigh: 40,
51
+ // Mobile content-region fallback OFF by default on both profiles. The
52
+ // tilt-robust real-contour path (minAreaRect) + chroma fusion detect cards
53
+ // directly, so the looser synthetic fallback is mostly a false-positive
54
+ // source. Re-enable live via the panel to compare.
55
+ mobileRegionFallback: false,
56
+ // False-positive controls. minAreaRect gives the card's true aspect, so a
57
+ // tight id-card window (1.585 ± 12% = [1.395, 1.775]) excludes 16:9 screens
58
+ // and most non-card rectangles. Passport/greenbook window (1.42 ± 10% =
59
+ // [1.278, 1.562]) is tight enough to exclude ID cards (1.585), monitors and
60
+ // phones. minFillRatio rejects ragged quads (rotated-rect fill, tilt-invariant).
61
+ idAspectTolerance: 0.12,
62
+ bookDocAspectTolerance: 0.1,
63
+ minFillRatio: 0.8,
64
+ minChromaContent: 13,
65
+ // Seam rejection: reject a card-shaped quad whose edges sit on long straight
66
+ // background lines that overshoot its corners (parquet floor, slatted table),
67
+ // detected via HoughLinesP on the contour edge map. Only ADDS rejections —
68
+ // off, or with no through-lines present, detection is unchanged. houghThreshold
69
+ // is the accumulator vote count; houghMinLengthRatio is the minimum line length
70
+ // as a fraction of the smaller ROI side; houghMaxLineGap bridges dashed edges.
71
+ seamRejectEnabled: true,
72
+ houghThreshold: 40,
73
+ houghMinLengthRatio: 0.3,
74
+ houghMaxLineGap: 10,
75
+ // Clutter guard: skip seam-rejection when HoughLinesP returns more lines than
76
+ // this — a woven fabric/carpet floods the map (~400+) and would falsely reject
77
+ // a real card, whereas a parquet shows only a handful of seam lines.
78
+ seamMaxHoughLines: 60,
79
+ // Clutter-adaptive Canny floor: on a near-empty scene (edgeDensity below
80
+ // lowClutterEdgeDensity %) drop the high-threshold floor to
81
+ // cannyHighMinLowClutter so a faint border (pale ID on pale wood) is still
82
+ // traced; busy scenes keep the fixed 60 floor so the high-contrast path holds.
83
+ lowClutterEdgeDensity: 2,
84
+ cannyHighMinLowClutter: 40,
85
+ // Gate-0 grid coverage is an early-out only: bail just on a near-empty grid
86
+ // (this many of 9 inner cells must carry edges). Distance / "fully visible" is
87
+ // owned downstream by docFillPercent >= minFillPercent (65%), so a strict bar
88
+ // here only false-rejected low-contrast cards on plain backgrounds before the
89
+ // contour pass ran. Synthetic-fallback eligibility keeps its own 7/9 signal.
90
+ captureGridMinCells: 4,
91
+ // Throttle the heavy CV pipeline to this processing rate (fps). rAF runs at the
92
+ // display refresh (60/90/120Hz), so a time-based throttle keeps detection — and
93
+ // the frame-count constants tuned against it — consistent across devices and
94
+ // saves ~2x CV cost on mobile. 60 effectively disables throttling.
95
+ targetProcessingFps: 30,
96
+ // Chroma-mask fallback: when no 4-corner quad forms in luminance (a strongly
97
+ // coloured card on a near-neutral background, e.g. a green/yellow ID on grey
98
+ // fabric), segment the card by chroma magnitude and accept the largest blob if
99
+ // it passes the same fill/aspect/wall-hug gates as a real contour. Gated to
100
+ // mobile (chroma fusion path) by the hook. KNOWN LIMITATION: a colourful
101
+ // rug/carpet patch is classically indistinguishable and can pass — toggle off
102
+ // here (or in the panel) if it false-captures. chromaMaskThreshold is the
103
+ // |a-128|+|b-128| binary cutoff; chromaMaskMinFrac/MaxFrac bound the blob's
104
+ // share of the ROI (a chromatic background spanning the ROI exceeds MaxFrac).
105
+ chromaMaskFallback: true,
106
+ chromaMaskThreshold: 18,
107
+ chromaMaskMinFrac: 0.08,
108
+ chromaMaskMaxFrac: 0.7,
109
+ cropToCard: true,
110
+ cropToContour: true,
111
+ cropPadding: 10,
112
+ previewCropPadding: 2,
113
+ };
114
+
115
+ // Per-device overrides. Only the knobs that genuinely differ between a
116
+ // hand-held phone camera and a fixed webcam appear here, each with its reason —
117
+ // everything else comes from SHARED_DEFAULTS. The divergent numbers don't share
118
+ // a common scale factor (they were measured independently per device), so they
119
+ // stay as explicit, documented values rather than a synthetic multiplier.
120
+ const MOBILE_OVERRIDES = {
121
+ deviceType: 'Mobile',
122
+ // Show the detected card outline only on mobile (handheld framing aid).
123
+ useDynamicBorder: true,
124
+ autoCannySigma: 0.0, // mobile cameras resolve more detail, so a lower threshold is needed to detect faint edges
125
+ edgeDensityThreshold: 6,
126
+ // Phone framing is looser, so each grid cell needs only 50% of the threshold.
127
+ gridCellRatio: 0.5,
128
+ // Fix 2: OR chroma (Lab a/b) edges into the contour edge map so a card whose
129
+ // border is invisible in luminance (beige ID on light wood) is still detected.
130
+ // Mobile only — desktop has a working high-contrast path and stays off.
131
+ chromaEdgeFusion: true,
132
+ // Level 2: reject near-monochrome winners (white keyboard, blank paper) by
133
+ // mean chroma over the detected rect. Needs chroma fusion, so mobile only.
134
+ // 13 sits in the measured gap between a white keyboard (~10) and a real
135
+ // colour ID (~17-26).
136
+ chromaContentGate: true,
137
+ // Phone cameras resolve more detail, so demand a higher sharpness floor.
138
+ blurThreshold: 150,
139
+ // Phone flash/specular highlights are harsh and localized — strict glare cap.
140
+ glareThreshold: 5.0,
141
+ // Handheld motion → require more stable frames before auto-capture.
142
+ stabilityThreshold: 5,
143
+ minFillPercent: 65,
144
+ maxFillPercent: 95,
145
+ // Anti-flicker (mobile only — handheld jitter is the problem; webcams are
146
+ // steady so desktop keeps today's exact behavior via the hook's ?? fallbacks).
147
+ // gateDecayEnabled: on a transient gate failure, decay the stability count by
148
+ // 1 (within the blur/glare miss tolerance) instead of zeroing it, so a single
149
+ // jittery frame doesn't drain the progress ring or flip "Hold Still"↔"Align".
150
+ gateDecayEnabled: true,
151
+ // docFillEmaAlpha: EMA smoothing of the distance fill % (1 = off). At the
152
+ // default 30fps throttle the EMA updates ~half as often as the old 60fps loop,
153
+ // so 0.45 keeps a similar wall-clock time constant (~3 processed frames)
154
+ // without over-lagging a real move.
155
+ docFillEmaAlpha: 0.45,
156
+ // fillHysteresis: deadband (pct points) around min/maxFillPercent so a hand
157
+ // hovering on the 65%/95% boundary doesn't toggle the distance gate.
158
+ fillHysteresis: 3,
159
+ };
160
+
161
+ const DESKTOP_OVERRIDES = {
162
+ deviceType: 'Desktop',
163
+ useDynamicBorder: false,
164
+ edgeDensityThreshold: 10,
165
+ // Webcam ROI is the visible box the user fills — stricter per-cell coverage.
166
+ gridCellRatio: 0.6,
167
+ chromaEdgeFusion: false,
168
+ chromaContentGate: false,
169
+ // Fixed-focus webcams are softer; a 150 floor would never pass, so use 60.
170
+ blurThreshold: 60,
171
+ // Webcams sit under diffuse room light — much more glare is normal/acceptable.
172
+ glareThreshold: 18.0,
173
+ // Webcam on a stand is steady, so fewer stable frames are needed.
174
+ stabilityThreshold: 3,
175
+ // Desktop ROI == the visible video box (see useCardDetection's skipGridCheck
176
+ // branch), so these percentages are measured against what the user actually
177
+ // sees. Require ~70% area fill (~84% linear — still ~990px of card width on a
178
+ // 720p webcam) before quality checks; allow up to 98% before backing off. The
179
+ // lower floor lets fixed-focus webcams sit at a sharper distance.
180
+ minFillPercent: 70,
181
+ maxFillPercent: 98,
182
+ };
183
+
184
+ const getOptimalDefaults = () => {
185
+ const ua =
186
+ navigator.userAgent ||
187
+ navigator.vendor ||
188
+ (window as unknown as { opera?: string }).opera ||
189
+ '';
190
+ const isMobile =
191
+ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
192
+
193
+ return {
194
+ ...SHARED_DEFAULTS,
195
+ ...(isMobile ? MOBILE_OVERRIDES : DESKTOP_OVERRIDES),
196
+ };
197
+ };
198
+
199
+ const galleryButtonStyle = {
200
+ width: 72,
201
+ height: 72,
202
+ borderRadius: '50%',
203
+ border: 'none',
204
+ backgroundColor: 'transparent',
205
+ color: '#fff',
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ justifyContent: 'center',
209
+ cursor: 'pointer',
210
+ boxShadow: 'none',
211
+ padding: 0,
212
+ };
213
+
214
+ const FEEDBACK_MIN_DISPLAY_MS = 500;
215
+
216
+ function GalleryIcon() {
217
+ return (
218
+ <svg
219
+ width="56"
220
+ height="56"
221
+ viewBox="0 0 56 56"
222
+ fill="none"
223
+ xmlns="http://www.w3.org/2000/svg"
224
+ aria-hidden="true"
225
+ >
226
+ <mask id="gallery-btn-inside" fill="white">
227
+ <path d="M0 28C0 12.536 12.536 0 28 0C43.464 0 56 12.536 56 28C56 43.464 43.464 56 28 56C12.536 56 0 43.464 0 28Z" />
228
+ </mask>
229
+ <path
230
+ d="M0 28C0 12.536 12.536 0 28 0C43.464 0 56 12.536 56 28C56 43.464 43.464 56 28 56C12.536 56 0 43.464 0 28Z"
231
+ fill="#151F72"
232
+ />
233
+ <path
234
+ d="M0 28M56 28M56 28M0 28M28 0M56 28M28 56M0 28M28 56V55C13.0883 55 1 42.9117 1 28H0H-1C-1 44.0163 11.9837 57 28 57V56ZM56 28H55C55 42.9117 42.9117 55 28 55V56V57C44.0163 57 57 44.0163 57 28H56ZM28 0V1C42.9117 1 55 13.0883 55 28H56H57C57 11.9837 44.0163 -1 28 -1V0ZM28 0V-1C11.9837 -1 -1 11.9837 -1 28H0H1C1 13.0883 13.0883 1 28 1V0Z"
235
+ fill="white"
236
+ fillOpacity="0.1"
237
+ mask="url(#gallery-btn-inside)"
238
+ />
239
+ <path
240
+ d="M35 19H21C19.8954 19 19 19.8954 19 21V35C19 36.1046 19.8954 37 21 37H35C36.1046 37 37 36.1046 37 35V21C37 19.8954 36.1046 19 35 19Z"
241
+ stroke="white"
242
+ strokeWidth="2"
243
+ strokeLinecap="round"
244
+ strokeLinejoin="round"
245
+ />
246
+ <path
247
+ d="M25 27C26.1046 27 27 26.1046 27 25C27 23.8954 26.1046 23 25 23C23.8954 23 23 23.8954 23 25C23 26.1046 23.8954 27 25 27Z"
248
+ stroke="white"
249
+ strokeWidth="2"
250
+ strokeLinecap="round"
251
+ strokeLinejoin="round"
252
+ />
253
+ <path
254
+ d="M37 30.9999L33.914 27.9139C33.5389 27.539 33.0303 27.3284 32.5 27.3284C31.9697 27.3284 31.4611 27.539 31.086 27.9139L22 36.9999"
255
+ stroke="white"
256
+ strokeWidth="2"
257
+ strokeLinecap="round"
258
+ strokeLinejoin="round"
259
+ />
260
+ </svg>
261
+ );
262
+ }
263
+
264
+ function GalleryButton({ onClick }: { onClick: () => void }) {
265
+ return (
266
+ <button
267
+ onClick={onClick}
268
+ style={galleryButtonStyle}
269
+ aria-label={translate('document.autoCapture.galleryButtonLabel')}
270
+ >
271
+ <GalleryIcon />
272
+ </button>
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Inner implementation that owns the camera + detection loop. Only mounted
278
+ * when the host element is actually visible (see `DocumentAutoCapture`
279
+ * wrapper below) so that two sibling instances — front + back — don't both
280
+ * fight for `getUserMedia` and run rAF/OpenCV detection while one is
281
+ * `hidden`. That collision was causing the page to freeze when the element
282
+ * was used inside `<document-capture-screens>`.
283
+ */
284
+ function DesktopCaptureButton({
285
+ progress = 0,
286
+ themeColor = '#001096',
287
+ disabled = false,
288
+ onClick,
289
+ }: {
290
+ progress: number;
291
+ themeColor: string;
292
+ disabled: boolean;
293
+ onClick: () => void;
294
+ }) {
295
+ const size = 70;
296
+ const strokeWidth = 4;
297
+ const ringRadius = size / 2 - strokeWidth / 2;
298
+ const circumference = 2 * Math.PI * ringRadius;
299
+ const offset = circumference - (progress / 100) * circumference;
300
+ const isActive = progress > 0 && progress < 100;
301
+
302
+ return (
303
+ <button
304
+ onClick={onClick}
305
+ disabled={disabled}
306
+ style={{
307
+ position: 'relative',
308
+ width: size,
309
+ height: size,
310
+ borderRadius: '50%',
311
+ border: 'none',
312
+ background: 'transparent',
313
+ cursor: disabled ? 'not-allowed' : 'pointer',
314
+ padding: 0,
315
+ opacity: disabled ? 0.4 : 1,
316
+ transition: 'opacity 0.2s ease',
317
+ WebkitTapHighlightColor: 'transparent',
318
+ flexShrink: 0,
319
+ }}
320
+ aria-label={translate('document.autoCapture.capturePhotoButton')}
321
+ >
322
+ <svg
323
+ xmlns="http://www.w3.org/2000/svg"
324
+ width={size}
325
+ height={size}
326
+ viewBox="0 0 70 70"
327
+ fill="none"
328
+ aria-hidden="true"
329
+ style={{ display: 'block' }}
330
+ >
331
+ <path
332
+ fillRule="evenodd"
333
+ clipRule="evenodd"
334
+ d="M35 70C54.33 70 70 54.33 70 35C70 15.67 54.33 0 35 0C15.67 0 0 15.67 0 35C0 54.33 15.67 70 35 70ZM61 35C61 49.3594 49.3594 61 35 61C20.6406 61 9 49.3594 9 35C9 20.6406 20.6406 9 35 9C49.3594 9 61 20.6406 61 35ZM65 35C65 51.5685 51.5685 65 35 65C18.4315 65 5 51.5685 5 35C5 18.4315 18.4315 5 35 5C51.5685 5 65 18.4315 65 35Z"
335
+ fill={themeColor}
336
+ />
337
+ </svg>
338
+ {isActive && (
339
+ <svg
340
+ width={size}
341
+ height={size}
342
+ style={{
343
+ position: 'absolute',
344
+ top: 0,
345
+ left: 0,
346
+ pointerEvents: 'none',
347
+ }}
348
+ aria-hidden="true"
349
+ >
350
+ <circle
351
+ cx={size / 2}
352
+ cy={size / 2}
353
+ r={ringRadius}
354
+ fill="none"
355
+ stroke="#2CC05C"
356
+ strokeWidth={strokeWidth}
357
+ strokeDasharray={circumference}
358
+ strokeDashoffset={offset}
359
+ strokeLinecap="round"
360
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
361
+ style={{ transition: 'stroke-dashoffset 0.3s ease' }}
362
+ />
363
+ </svg>
364
+ )}
365
+ </button>
366
+ );
367
+ }
368
+ const AUTO_CAPTURE_TIMEOUT_MIN_MS = 3_000;
369
+ const AUTO_CAPTURE_TIMEOUT_MAX_MS = 30_000;
370
+ const AUTO_CAPTURE_TIMEOUT_DEFAULT_MS = 20_000;
371
+ const DocumentAutoCaptureInner: FunctionComponent<Props> = ({
372
+ 'document-type': documentTypeProp = '',
373
+ 'auto-capture': captureModeProp = 'autoCapture',
374
+ 'auto-capture-timeout':
375
+ autoCaptureTimeoutProp = AUTO_CAPTURE_TIMEOUT_DEFAULT_MS,
376
+ 'side-of-id': sideOfId = 'Front',
377
+ 'theme-color': themeColor = '#001096',
378
+ title = '',
379
+ 'show-navigation': showNavigationProp = false,
380
+ 'allow-gallery-upload': allowGalleryUploadProp = true,
381
+ 'document-capture-modes': documentCaptureModesProp,
382
+ 'sync-roi-to-guide': syncRoiToGuideProp = true,
383
+ }) => {
384
+ const galleryInputRef = useRef<HTMLInputElement | null>(null);
385
+ const captureFiredRef = useRef(false);
386
+
387
+ const showNavigation = getBoolProp(showNavigationProp);
388
+ // Honour `document-capture-modes`: when explicitly set to a value that
389
+ // does not include `upload` (e.g. just `camera`), the gallery upload
390
+ // affordance must be hidden regardless of the `allow-gallery-upload`
391
+ // default. This mirrors how <document-capture-instructions> gates its
392
+ // upload button on the same attribute.
393
+ const captureModesAllowUpload = (() => {
394
+ if (
395
+ documentCaptureModesProp === undefined ||
396
+ documentCaptureModesProp === null
397
+ ) {
398
+ return true;
399
+ }
400
+ const modes = String(documentCaptureModesProp)
401
+ .toLowerCase()
402
+ .split(',')
403
+ .map((m) => m.trim())
404
+ .filter(Boolean);
405
+ if (modes.length === 0) return true;
406
+ return modes.includes('upload');
407
+ })();
408
+ const allowGalleryUpload =
409
+ getBoolProp(allowGalleryUploadProp, true) && captureModesAllowUpload;
410
+ const syncRoiToGuide = getBoolProp(syncRoiToGuideProp, true);
411
+
412
+ const captureMode: CaptureMode = CAPTURE_MODES.includes(
413
+ captureModeProp as CaptureMode,
414
+ )
415
+ ? (captureModeProp as CaptureMode)
416
+ : 'autoCapture';
417
+ // Clamp to the documented 3000–30000ms range. Values outside this band
418
+ // tend to either fire the manual fallback before the user has a chance
419
+ // to align the document (too low) or never surface it at all (too high).
420
+ const autoCaptureTimeout = (() => {
421
+ const n = Number(autoCaptureTimeoutProp);
422
+ if (!Number.isFinite(n) || n <= 0) return AUTO_CAPTURE_TIMEOUT_DEFAULT_MS;
423
+ return Math.min(
424
+ AUTO_CAPTURE_TIMEOUT_MAX_MS,
425
+ Math.max(AUTO_CAPTURE_TIMEOUT_MIN_MS, n),
426
+ );
427
+ })();
428
+
429
+ // Map upper-case enum values used elsewhere in web-components (GREEN_BOOK,
430
+ // ID_CARD, PASSPORT) to the lower-case keys used by useCardDetection.
431
+ const documentType = ((): 'greenbook' | 'id-card' | 'passport' | null => {
432
+ if (!documentTypeProp) return null;
433
+ const v = String(documentTypeProp).toLowerCase();
434
+ if (v === 'green_book' || v === 'greenbook') return 'greenbook';
435
+ if (v === 'id_card' || v === 'id-card') return 'id-card';
436
+ if (v === 'passport') return 'passport';
437
+ return 'id-card';
438
+ })();
439
+
440
+ const [settings, setSettings] = useState(getOptimalDefaults());
441
+ // Track the camera-viewport box (the absolute-positioned div that fills the
442
+ // host) rather than the page viewport, so the component fills its parent
443
+ // even when embedded inside another layout (e.g. <document-capture-screens>).
444
+ const cameraViewportRef = useRef<HTMLDivElement>(null);
445
+ // The shared <smileid-navigation> element (only one layout mounts at a time).
446
+ const navigationRef = useRef<HTMLElement | null>(null);
447
+ const [viewportBox, setViewportBox] = useState<{ w: number; h: number }>({
448
+ w: 0,
449
+ h: 0,
450
+ });
451
+ const isTallViewport = viewportBox.h > viewportBox.w;
452
+ const updateSetting = (key: string, value: unknown) =>
453
+ setSettings((prev) => ({ ...prev, [key]: value }));
454
+ // Debug UI (tuning panel + ROI overlay) is compiled in for dev + preview only
455
+ // (see utils/debug.ts / __SMILE_DEBUG__); production builds strip it.
456
+ const showDebug = isDebugEnabled();
457
+
458
+ // Lazy-load OpenCV on mount; the detection hook polls for `cv.Mat`.
459
+ useEffect(() => {
460
+ ensureOpenCv().catch((err: unknown) => {
461
+ console.warn('[document-auto-capture] OpenCV load failed:', err);
462
+ });
463
+ }, []);
464
+
465
+ // Observe the camera viewport's box for orientation + rotation sizing.
466
+ useEffect(() => {
467
+ const el = cameraViewportRef.current;
468
+ if (!el || typeof ResizeObserver === 'undefined') return undefined;
469
+
470
+ const update = () => {
471
+ setViewportBox({ w: el.clientWidth, h: el.clientHeight });
472
+ };
473
+ update();
474
+
475
+ const ro = new ResizeObserver(update);
476
+ ro.observe(el);
477
+ return () => ro.disconnect();
478
+ }, []);
479
+
480
+ const isLandscapeDocumentType =
481
+ documentType === 'id-card' || documentType === 'passport';
482
+ const useLandscapeUi = isLandscapeDocumentType;
483
+ // Rotate the UI 90 degrees only on touch devices (mobile / tablet) where
484
+ // the user is expected to be holding the phone in portrait. On desktop /
485
+ // laptop with a portrait-shaped parent box (e.g. the dev playground
486
+ // 360px column) we keep the un-rotated layout because the user can't
487
+ // rotate their monitor.
488
+ const isMobileDevice = settings.deviceType === 'Mobile';
489
+ const shouldRotateUi = useLandscapeUi && isTallViewport && isMobileDevice;
490
+ const effectiveCaptureOrientation = isLandscapeDocumentType
491
+ ? 'landscape'
492
+ : 'portrait';
493
+
494
+ const { videoRef, error } = useCamera();
495
+ const {
496
+ feedback,
497
+ captureProgress,
498
+ capturedImage,
499
+ previewImage,
500
+ captureOrigin,
501
+ complianceState,
502
+ debugInfo,
503
+ debugPath,
504
+ debugRoi,
505
+ detectedDocType,
506
+ guideAspectRatio,
507
+ manualFallbackActive,
508
+ cvLoadFailed,
509
+ triggerManualCapture,
510
+ } = useCardDetection(videoRef, settings, {
511
+ variant: 'fullscreen',
512
+ documentType,
513
+ captureMode,
514
+ autoCaptureTimeout,
515
+ captureOrientation: effectiveCaptureOrientation,
516
+ shouldRotateUi,
517
+ syncRoiToGuide,
518
+ skipGridCheck: settings.deviceType !== 'Mobile',
519
+ });
520
+
521
+ const [visibleFeedback, setVisibleFeedback] = useState<string>(feedback);
522
+ const feedbackHoldUntilRef = useRef(0);
523
+ const pendingFeedbackRef = useRef<string | null>(null);
524
+ const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
525
+
526
+ useEffect(() => {
527
+ const immediateState =
528
+ complianceState === COMPLIANCE_STATES.CAPTURING ||
529
+ complianceState === COMPLIANCE_STATES.SUCCESS;
530
+
531
+ if (immediateState || !visibleFeedback) {
532
+ if (feedbackTimerRef.current) {
533
+ clearTimeout(feedbackTimerRef.current);
534
+ feedbackTimerRef.current = null;
535
+ }
536
+ pendingFeedbackRef.current = null;
537
+ setVisibleFeedback(feedback);
538
+ feedbackHoldUntilRef.current = Date.now() + FEEDBACK_MIN_DISPLAY_MS;
539
+ return;
540
+ }
541
+
542
+ if (feedback === visibleFeedback) {
543
+ if (feedbackTimerRef.current) {
544
+ clearTimeout(feedbackTimerRef.current);
545
+ feedbackTimerRef.current = null;
546
+ }
547
+ pendingFeedbackRef.current = null;
548
+ return;
549
+ }
550
+
551
+ const now = Date.now();
552
+ const remaining = feedbackHoldUntilRef.current - now;
553
+
554
+ if (remaining <= 0) {
555
+ setVisibleFeedback(feedback);
556
+ feedbackHoldUntilRef.current = now + FEEDBACK_MIN_DISPLAY_MS;
557
+ return;
558
+ }
559
+
560
+ pendingFeedbackRef.current = feedback;
561
+ if (feedbackTimerRef.current) return;
562
+
563
+ feedbackTimerRef.current = setTimeout(() => {
564
+ feedbackTimerRef.current = null;
565
+ if (pendingFeedbackRef.current) {
566
+ setVisibleFeedback(pendingFeedbackRef.current);
567
+ pendingFeedbackRef.current = null;
568
+ feedbackHoldUntilRef.current = Date.now() + FEEDBACK_MIN_DISPLAY_MS;
569
+ }
570
+ }, remaining);
571
+ }, [feedback, complianceState, visibleFeedback]);
572
+
573
+ useEffect(
574
+ () => () => {
575
+ if (feedbackTimerRef.current) {
576
+ clearTimeout(feedbackTimerRef.current);
577
+ feedbackTimerRef.current = null;
578
+ }
579
+ },
580
+ [],
581
+ );
582
+
583
+ // Debounced compliance state for visual output.
584
+ // The raw complianceState updates every detection frame. Feeding it directly
585
+ // to the Overlay/spinner causes rapid color oscillation when quality is
586
+ // borderline. Debounce is ASYMMETRIC: snap immediately INTO the "good" green
587
+ // states (STABLE/CAPTURING/SUCCESS) so the guide turns green the instant the
588
+ // Hold-Still phase begins — that phase is only ~5 frames, shorter than the
589
+ // debounce, so a trailing debounce would skip green entirely — but apply the
590
+ // 150ms trailing debounce when DOWNGRADING to DETECTING/IDLE so a transient
591
+ // miss doesn't flash the border back to amber.
592
+ const COMPLIANCE_DEBOUNCE_MS = 150;
593
+ const [visibleComplianceState, setVisibleComplianceState] =
594
+ useState(complianceState);
595
+ useEffect(() => {
596
+ const isGoodState =
597
+ complianceState === COMPLIANCE_STATES.STABLE ||
598
+ complianceState === COMPLIANCE_STATES.CAPTURING ||
599
+ complianceState === COMPLIANCE_STATES.SUCCESS;
600
+ const timer = setTimeout(
601
+ () => setVisibleComplianceState(complianceState),
602
+ isGoodState ? 0 : COMPLIANCE_DEBOUNCE_MS,
603
+ );
604
+ return () => clearTimeout(timer);
605
+ }, [complianceState]);
606
+
607
+ // Notify smart-camera-web when the capture session begins.
608
+ useEffect(() => {
609
+ const smartCameraWeb = document.querySelector('smart-camera-web');
610
+ smartCameraWeb?.dispatchEvent(
611
+ new CustomEvent('metadata.document-capture-start', {
612
+ detail: { side: sideOfId },
613
+ }),
614
+ );
615
+ return () => {
616
+ smartCameraWeb?.dispatchEvent(
617
+ new CustomEvent('metadata.document-capture-end', {
618
+ detail: { side: sideOfId },
619
+ }),
620
+ );
621
+ };
622
+ }, [sideOfId]);
623
+
624
+ // Resolve THIS instance's host element (not the first match in the
625
+ // document) by walking up from a node inside the shadow root. Using
626
+ // `document.querySelector('document-auto-capture')` here is wrong when
627
+ // there are sibling instances (front + back), because it always returns
628
+ // the front and the back's events would never reach its listener.
629
+ const getHost = (): Element | null => {
630
+ const node =
631
+ cameraViewportRef.current || videoRef.current || galleryInputRef.current;
632
+ const root = node?.getRootNode();
633
+ if (root && root instanceof ShadowRoot) return root.host;
634
+ return document.querySelector('document-auto-capture');
635
+ };
636
+
637
+ // Re-encode at JPEG_QUALITY before publishing so output matches the legacy
638
+ // `<document-capture>` element exactly. The detection hook captures at 0.95
639
+ // quality for internal use; we round-trip via an Image to set the package's
640
+ // canonical JPEG_QUALITY.
641
+ const reencodeJpeg = (
642
+ dataUrl: string,
643
+ ): Promise<{ data: string; width: number; height: number }> =>
644
+ new Promise((resolve, reject) => {
645
+ const img = new Image();
646
+ img.onload = () => {
647
+ try {
648
+ const canvas = document.createElement('canvas');
649
+ canvas.width = img.width;
650
+ canvas.height = img.height;
651
+ const ctx = canvas.getContext('2d');
652
+ if (!ctx) {
653
+ reject(new Error('2d context unavailable'));
654
+ return;
655
+ }
656
+ ctx.drawImage(img, 0, 0);
657
+ resolve({
658
+ data: canvas.toDataURL('image/jpeg', JPEG_QUALITY),
659
+ width: canvas.width,
660
+ height: canvas.height,
661
+ });
662
+ } catch (err) {
663
+ reject(err);
664
+ }
665
+ };
666
+ img.onerror = reject;
667
+ img.src = dataUrl;
668
+ });
669
+
670
+ const publishImage = (
671
+ dataUrl: string,
672
+ origin: string,
673
+ preview: string | null,
674
+ ) => {
675
+ if (!dataUrl || captureFiredRef.current) return;
676
+ captureFiredRef.current = true;
677
+
678
+ const fullPromise = reencodeJpeg(dataUrl);
679
+ const previewPromise = preview
680
+ ? reencodeJpeg(preview)
681
+ : Promise.resolve(null);
682
+
683
+ Promise.all([fullPromise, previewPromise])
684
+ .then(([full, prev]) => {
685
+ const finalImage = full.data;
686
+ const previewOut = prev ? prev.data : finalImage;
687
+
688
+ // Use the same event surface as the legacy element for drop-in parity.
689
+ const host = getHost();
690
+ const target = host || document;
691
+ target.dispatchEvent(
692
+ new CustomEvent('document-capture.publish', {
693
+ bubbles: true,
694
+ composed: true,
695
+ detail: {
696
+ image: finalImage,
697
+ originalHeight: full.height,
698
+ originalWidth: full.width,
699
+ previewImage: previewOut,
700
+ side: sideOfId,
701
+ captureOrigin: origin,
702
+ },
703
+ }),
704
+ );
705
+ })
706
+ .catch(() => {
707
+ console.error('[document-auto-capture] failed to decode capture');
708
+ });
709
+ };
710
+
711
+ useEffect(() => {
712
+ if (capturedImage) {
713
+ publishImage(
714
+ capturedImage,
715
+ captureOrigin || 'camera_auto_capture',
716
+ previewImage,
717
+ );
718
+ }
719
+ }, [capturedImage]);
720
+
721
+ // Wire up navigation back/close events.
722
+ const dispatchHostEvent = (name: string) => {
723
+ const host = getHost();
724
+ (host || document).dispatchEvent(
725
+ new CustomEvent(name, { bubbles: true, composed: true }),
726
+ );
727
+ };
728
+
729
+ // `document-capture.*` is the canonical event name: it matches the legacy
730
+ // <document-capture> element (so this stays a drop-in replacement), the
731
+ // README, and the listener DocumentCaptureScreens binds for "cancelled".
732
+ // The `document-auto-capture.*` variant is also emitted so the screens
733
+ // wrapper's dynamic `${nodeName}.close` listener still fires.
734
+ const onBack = () => {
735
+ dispatchHostEvent('document-capture.cancelled');
736
+ dispatchHostEvent('document-auto-capture.cancelled');
737
+ };
738
+ const onClose = () => {
739
+ dispatchHostEvent('document-capture.close');
740
+ dispatchHostEvent('document-auto-capture.close');
741
+ };
742
+
743
+ // Bridge the <smileid-navigation> element's custom events to the same
744
+ // back/close handlers. Mirrors SmartSelfieCapture's wiring; only one layout
745
+ // (and thus one navigation element) is mounted at a time, so a single ref
746
+ // suffices.
747
+ useEffect(() => {
748
+ const navigation = navigationRef.current;
749
+ if (!navigation || !showNavigation) return undefined;
750
+ const handleBack = () => onBack();
751
+ const handleClose = () => onClose();
752
+ navigation.addEventListener('navigation.back', handleBack);
753
+ navigation.addEventListener('navigation.close', handleClose);
754
+ return () => {
755
+ navigation.removeEventListener('navigation.back', handleBack);
756
+ navigation.removeEventListener('navigation.close', handleClose);
757
+ };
758
+ }, [showNavigation]);
759
+
760
+ // Capture-button ring progress. `captureProgress` (0–100) already reflects
761
+ // the stability count vs the threshold; the previous `debugInfo.stability`
762
+ // lookup was always undefined (the hook never sets that field), so the ring
763
+ // stayed at 0.
764
+ const progress =
765
+ visibleComplianceState === COMPLIANCE_STATES.STABLE ? captureProgress : 0;
766
+
767
+ // Whether to show the manual capture button.
768
+ // manualCaptureOnly — always show
769
+ // autoCapture — show after timeout fallback or if OpenCV failed to load
770
+ // autoCaptureOnly — never show (error UI handles cv load failure)
771
+ const showManualButton =
772
+ captureMode === 'manualCaptureOnly' ||
773
+ (captureMode === 'autoCapture' && (manualFallbackActive || cvLoadFailed));
774
+
775
+ if (error) {
776
+ return (
777
+ <div
778
+ style={{
779
+ flex: 1,
780
+ display: 'flex',
781
+ flexDirection: 'column',
782
+ alignItems: 'center',
783
+ justifyContent: 'center',
784
+ padding: theme.spacing.xl,
785
+ textAlign: 'center',
786
+ color: theme.colors.error,
787
+ }}
788
+ >
789
+ <svg
790
+ width="48"
791
+ height="48"
792
+ viewBox="0 0 24 24"
793
+ fill="none"
794
+ stroke="currentColor"
795
+ strokeWidth="2"
796
+ >
797
+ <circle cx="12" cy="12" r="10" />
798
+ <line x1="15" y1="9" x2="9" y2="15" />
799
+ <line x1="9" y1="9" x2="15" y2="15" />
800
+ </svg>
801
+ <p style={{ marginTop: theme.spacing.md, fontSize: '1rem' }}>
802
+ {translate('document.autoCapture.error.cameraUnavailable.title')}
803
+ </p>
804
+ <p
805
+ style={{
806
+ fontSize: '0.85rem',
807
+ color: theme.colors.textSecondary,
808
+ marginTop: theme.spacing.sm,
809
+ }}
810
+ >
811
+ {translate('document.autoCapture.error.cameraUnavailable.body')}
812
+ </p>
813
+ </div>
814
+ );
815
+ }
816
+
817
+ /* ---- Fullscreen layout ---- */
818
+ const baseShowSideSpinner = [
819
+ COMPLIANCE_STATES.DETECTING,
820
+ COMPLIANCE_STATES.STABLE,
821
+ COMPLIANCE_STATES.CAPTURING,
822
+ ].includes(visibleComplianceState);
823
+
824
+ let spinnerProgress: number;
825
+ if (visibleComplianceState === COMPLIANCE_STATES.STABLE) {
826
+ spinnerProgress = Math.max(15, progress);
827
+ } else if (visibleComplianceState === COMPLIANCE_STATES.CAPTURING) {
828
+ spinnerProgress = 99;
829
+ } else {
830
+ spinnerProgress = 25;
831
+ }
832
+
833
+ const showManualCaptureControl =
834
+ showManualButton ||
835
+ (allowGalleryUpload && captureMode !== 'autoCaptureOnly');
836
+
837
+ // Side-mounted controls are only used in the rotated UI. When the UI is
838
+ // not rotated (e.g. landscape doc type on desktop), buttons live in the
839
+ // bottom row alongside the portrait layout.
840
+ const useSideManualCapture = shouldRotateUi && showManualCaptureControl;
841
+ const showSideGalleryButton = shouldRotateUi && allowGalleryUpload;
842
+ const showBottomGalleryButton = allowGalleryUpload && !showSideGalleryButton;
843
+ // Only show the side progress spinner when the UI is actually rotated
844
+ // (landscape doc type on a portrait viewport). In portrait/un-rotated UI
845
+ // the bottom CaptureButton already shows progress, so a second spinner on
846
+ // the right would just be a duplicate floating button.
847
+ const showSideSpinner =
848
+ baseShowSideSpinner && shouldRotateUi && !useSideManualCapture;
849
+ const sideButtonProgress =
850
+ visibleComplianceState === COMPLIANCE_STATES.STABLE ? captureProgress : 0;
851
+
852
+ const handlePickFromGallery = () => {
853
+ if (!allowGalleryUpload) return;
854
+ galleryInputRef.current?.click();
855
+ };
856
+
857
+ const handleGalleryChange = (event: Event) => {
858
+ const target = event.target as HTMLInputElement | null;
859
+ const file = target?.files?.[0];
860
+ if (!file) return;
861
+ const reader = new FileReader();
862
+ reader.onload = () => {
863
+ const imageData =
864
+ typeof reader.result === 'string' ? reader.result : null;
865
+ if (imageData) publishImage(imageData, 'gallery', null);
866
+ };
867
+ reader.readAsDataURL(file);
868
+ if (target) target.value = '';
869
+ };
870
+
871
+ const containerStyle = {
872
+ width: '100%',
873
+ height: '100%',
874
+ display: 'flex',
875
+ flexDirection: 'column',
876
+ backgroundColor: '#000',
877
+ position: 'relative',
878
+ overflow: 'hidden',
879
+ fontFamily: theme.fonts.base,
880
+ };
881
+
882
+ // Inline styles for shadow-DOM host + global resets so the layout matches
883
+ // the id-scanner viewport (where these come from index.css globally).
884
+ const hostStyles = `
885
+ :host { display: block; width: 100%; height: 100%; }
886
+ *, *::before, *::after { box-sizing: border-box; }
887
+ `;
888
+
889
+ /* ---- Desktop layout ----
890
+ Matches the legacy `<document-capture>` visual style: optional nav
891
+ buttons at top, constrained video with a simple solid border whose
892
+ colour reflects detection state, title + dynamic feedback text below
893
+ the video, and the legacy concentric-circle capture button.
894
+ Auto-capture detection logic (useCardDetection) is unchanged.
895
+ */
896
+ if (!isMobileDevice) {
897
+ const borderColor = (() => {
898
+ if (
899
+ visibleComplianceState === COMPLIANCE_STATES.STABLE ||
900
+ visibleComplianceState === COMPLIANCE_STATES.SUCCESS ||
901
+ visibleComplianceState === COMPLIANCE_STATES.CAPTURING
902
+ ) {
903
+ return '#2CC05C';
904
+ }
905
+ if (visibleComplianceState === COMPLIANCE_STATES.DETECTING) {
906
+ return '#F59E0B';
907
+ }
908
+ return '#9394ab';
909
+ })();
910
+
911
+ const titleLabel = title || `Submit ${sideOfId} of ID`;
912
+
913
+ return (
914
+ <div
915
+ className="document-auto-capture document-auto-capture--desktop"
916
+ style={{
917
+ width: '100%',
918
+ height: '100%',
919
+ display: 'flex',
920
+ flexDirection: 'column',
921
+ backgroundColor: '#fff',
922
+ fontFamily: theme.fonts.base,
923
+ boxSizing: 'border-box',
924
+ }}
925
+ >
926
+ <style>{hostStyles}</style>
927
+
928
+ {allowGalleryUpload && (
929
+ <input
930
+ ref={galleryInputRef}
931
+ type="file"
932
+ accept="image/*"
933
+ onChange={handleGalleryChange}
934
+ style={{ display: 'none' }}
935
+ />
936
+ )}
937
+
938
+ {/* Navigation row — reuses the shared <smileid-navigation> element.
939
+ Light desktop chrome: dark icons on a faint grey pill, overriding
940
+ the element's default translucent-on-dark styling via CSS vars. */}
941
+ {showNavigation && (
942
+ <div style={{ padding: '0.75rem 1rem 0' }}>
943
+ {/* @ts-expect-error preact-custom-element lacks ref/attr types */}
944
+ <smileid-navigation
945
+ ref={navigationRef}
946
+ style={{
947
+ width: '100%',
948
+ '--smileid-navigation-button-bg': 'rgba(0,0,0,0.08)',
949
+ '--smileid-navigation-icon-color': 'rgba(0,0,0,0.7)',
950
+ '--smileid-navigation-focus-color': themeColor,
951
+ }}
952
+ />
953
+ </div>
954
+ )}
955
+
956
+ {/* Video area */}
957
+ <div
958
+ style={{
959
+ flex: 1,
960
+ display: 'flex',
961
+ alignItems: 'center',
962
+ justifyContent: 'center',
963
+ padding: '1rem',
964
+ overflow: 'hidden',
965
+ }}
966
+ >
967
+ <div
968
+ ref={cameraViewportRef}
969
+ style={{
970
+ position: 'relative',
971
+ width: '100%',
972
+ maxWidth: 480,
973
+ aspectRatio: `${guideAspectRatio} / 1`,
974
+ borderRadius: 4,
975
+ overflow: 'hidden',
976
+ border: `4px solid ${borderColor}`,
977
+ transition: 'border-color 0.25s ease',
978
+ backgroundColor: '#000',
979
+ }}
980
+ >
981
+ <video
982
+ ref={videoRef}
983
+ playsInline
984
+ muted
985
+ style={{
986
+ position: 'absolute',
987
+ top: 0,
988
+ left: 0,
989
+ width: '100%',
990
+ height: '100%',
991
+ objectFit: 'cover',
992
+ display: 'block',
993
+ }}
994
+ />
995
+ {/* Debug-only: outline the active detection ROI so threshold
996
+ issues (wall-hug, overflow, fill %) can be judged visually. */}
997
+ {showDebug && debugRoi ? (
998
+ <div
999
+ style={{
1000
+ position: 'absolute',
1001
+ left: debugRoi.x,
1002
+ top: debugRoi.y,
1003
+ width: debugRoi.w,
1004
+ height: debugRoi.h,
1005
+ border: '2px dashed #ff3b30',
1006
+ boxSizing: 'border-box',
1007
+ pointerEvents: 'none',
1008
+ zIndex: 5,
1009
+ }}
1010
+ >
1011
+ <span
1012
+ style={{
1013
+ position: 'absolute',
1014
+ top: 2,
1015
+ left: 4,
1016
+ color: '#ff3b30',
1017
+ font: '600 10px/1 sans-serif',
1018
+ textShadow: '0 0 2px rgba(0,0,0,0.8)',
1019
+ }}
1020
+ >
1021
+ ROI
1022
+ </span>
1023
+ </div>
1024
+ ) : null}
1025
+ </div>
1026
+ </div>
1027
+
1028
+ {/* Footer: title, feedback text, capture button */}
1029
+ <div
1030
+ style={{
1031
+ padding: '0 1.5rem 1.5rem',
1032
+ display: 'flex',
1033
+ flexDirection: 'column',
1034
+ alignItems: 'center',
1035
+ gap: '0.5rem',
1036
+ textAlign: 'center',
1037
+ }}
1038
+ >
1039
+ <h2
1040
+ style={{
1041
+ margin: 0,
1042
+ fontSize: '1rem',
1043
+ fontWeight: 700,
1044
+ color: themeColor,
1045
+ }}
1046
+ >
1047
+ {titleLabel}
1048
+ </h2>
1049
+ <p
1050
+ style={{
1051
+ margin: 0,
1052
+ fontSize: '0.9rem',
1053
+ color: '#333',
1054
+ minHeight: '1.25rem',
1055
+ }}
1056
+ >
1057
+ {visibleFeedback}
1058
+ </p>
1059
+
1060
+ <div
1061
+ style={{
1062
+ display: 'flex',
1063
+ gap: '1.25rem',
1064
+ alignItems: 'center',
1065
+ justifyContent: 'center',
1066
+ marginTop: '0.75rem',
1067
+ }}
1068
+ >
1069
+ {allowGalleryUpload && (
1070
+ <GalleryButton onClick={handlePickFromGallery} />
1071
+ )}
1072
+ {/* The manual shutter only appears when it can actually be used
1073
+ (showManualButton): immediately for manualCaptureOnly, after
1074
+ the auto-capture timeout fallback fires in autoCapture, or on
1075
+ CV load failure; never in autoCaptureOnly. Auto-capture state
1076
+ is conveyed by the video border, so no progress ring is needed
1077
+ while the shutter is hidden. */}
1078
+ {showManualButton && (
1079
+ <DesktopCaptureButton
1080
+ progress={
1081
+ visibleComplianceState === COMPLIANCE_STATES.STABLE
1082
+ ? captureProgress
1083
+ : 0
1084
+ }
1085
+ themeColor={themeColor}
1086
+ disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
1087
+ onClick={triggerManualCapture}
1088
+ />
1089
+ )}
1090
+ </div>
1091
+
1092
+ {captureMode === 'autoCaptureOnly' && cvLoadFailed && (
1093
+ <p
1094
+ style={{
1095
+ color: theme.colors.error,
1096
+ fontSize: '0.8rem',
1097
+ textAlign: 'center',
1098
+ margin: 0,
1099
+ }}
1100
+ >
1101
+ {translate('document.autoCapture.error.cvLoadFailed')}
1102
+ </p>
1103
+ )}
1104
+ </div>
1105
+
1106
+ {/* __SMILE_DEBUG__ is a build-time literal → this whole branch (and the
1107
+ TuningPanel import) is dead-code-eliminated from production bundles;
1108
+ showDebug then applies the runtime ?debug opt-in in dev + preview. */}
1109
+ {__SMILE_DEBUG__ && showDebug && (
1110
+ <TuningPanel
1111
+ settings={settings}
1112
+ updateSetting={updateSetting}
1113
+ debugInfo={debugInfo}
1114
+ />
1115
+ )}
1116
+ </div>
1117
+ );
1118
+ }
1119
+
1120
+ return (
1121
+ <div className="document-auto-capture" style={containerStyle}>
1122
+ <style>{hostStyles}</style>
1123
+
1124
+ {allowGalleryUpload && (
1125
+ <input
1126
+ ref={galleryInputRef}
1127
+ type="file"
1128
+ accept="image/*"
1129
+ onChange={handleGalleryChange}
1130
+ style={{ display: 'none' }}
1131
+ />
1132
+ )}
1133
+
1134
+ {/* Camera viewport — fills the host absolutely. The rotated overlay
1135
+ below uses the observed pixel dimensions of this box (via
1136
+ ResizeObserver) so it matches the visible camera area regardless
1137
+ of whether the host fills the page viewport or a smaller parent. */}
1138
+ <div
1139
+ ref={cameraViewportRef}
1140
+ style={{
1141
+ position: 'absolute',
1142
+ inset: 0,
1143
+ overflow: 'hidden',
1144
+ zIndex: 1,
1145
+ }}
1146
+ >
1147
+ <video
1148
+ ref={videoRef}
1149
+ playsInline
1150
+ muted
1151
+ style={{
1152
+ position: 'absolute',
1153
+ top: 0,
1154
+ left: 0,
1155
+ width: '100%',
1156
+ height: '100%',
1157
+ objectFit: 'cover',
1158
+ display: 'block',
1159
+ }}
1160
+ />
1161
+
1162
+ {/* UI overlay container — rotated 90° CW on portrait phones with landscape doc types */}
1163
+ <div
1164
+ style={{
1165
+ position: 'absolute',
1166
+ top: 0,
1167
+ left: 0,
1168
+ ...(shouldRotateUi
1169
+ ? {
1170
+ // Rotated 90° CW — swap parent box dimensions so the
1171
+ // rotated rectangle covers the camera viewport exactly.
1172
+ width: viewportBox.h ? `${viewportBox.h}px` : '100%',
1173
+ height: viewportBox.w ? `${viewportBox.w}px` : '100%',
1174
+ transformOrigin: '0 0',
1175
+ transform: 'rotate(90deg) translateY(-100%)',
1176
+ }
1177
+ : {
1178
+ width: '100%',
1179
+ height: '100%',
1180
+ }),
1181
+ pointerEvents: 'none',
1182
+ display: 'flex',
1183
+ flexDirection: 'column',
1184
+ zIndex: 5,
1185
+ }}
1186
+ >
1187
+ {/* Top controls — reuses the shared <smileid-navigation> element,
1188
+ spanning the top so Back lands top-left and Close top-right. The
1189
+ element's default translucent-on-dark styling already matches the
1190
+ fullscreen camera chrome. */}
1191
+ {showNavigation && (
1192
+ <div
1193
+ style={{
1194
+ position: 'absolute',
1195
+ top: 32,
1196
+ left: 16,
1197
+ right: 16,
1198
+ zIndex: 10,
1199
+ pointerEvents: 'auto',
1200
+ }}
1201
+ >
1202
+ {/* @ts-expect-error preact-custom-element lacks ref/attr types */}
1203
+ <smileid-navigation
1204
+ ref={navigationRef}
1205
+ style={{
1206
+ width: '100%',
1207
+ '--smileid-navigation-button-bg': 'rgba(0,0,0,0.55)',
1208
+ '--smileid-navigation-icon-color': '#fff',
1209
+ }}
1210
+ />
1211
+ </div>
1212
+ )}
1213
+
1214
+ {/* Detection overlay with guide box */}
1215
+ <Overlay
1216
+ complianceState={visibleComplianceState}
1217
+ debugPath={debugPath}
1218
+ showDebug={showDebug}
1219
+ guideAspectRatio={guideAspectRatio}
1220
+ detectedDocType={detectedDocType}
1221
+ sideOfId={sideOfId}
1222
+ isRotated={shouldRotateUi}
1223
+ />
1224
+
1225
+ {/* Side capture-progress button */}
1226
+ {showSideSpinner && (
1227
+ <div
1228
+ style={{
1229
+ position: 'absolute',
1230
+ right: 34,
1231
+ top: '50%',
1232
+ transform: 'translateY(-50%)',
1233
+ zIndex: 11,
1234
+ pointerEvents: 'auto',
1235
+ }}
1236
+ >
1237
+ <CaptureButton
1238
+ progress={spinnerProgress}
1239
+ disabled={false}
1240
+ appearance="light"
1241
+ onClick={() => {}}
1242
+ />
1243
+ </div>
1244
+ )}
1245
+
1246
+ {/* Side manual capture button — anchor the CaptureButton (72px) at
1247
+ the vertical center so the gallery button stacks below without
1248
+ shifting the shutter off-center. */}
1249
+ {(useSideManualCapture || showSideGalleryButton) && (
1250
+ <div
1251
+ style={{
1252
+ position: 'absolute',
1253
+ right: 22,
1254
+ top: 'calc(50% - 36px)',
1255
+ zIndex: 12,
1256
+ display: 'flex',
1257
+ flexDirection: 'column',
1258
+ gap: 10,
1259
+ alignItems: 'center',
1260
+ pointerEvents: 'auto',
1261
+ }}
1262
+ >
1263
+ {useSideManualCapture && (
1264
+ <CaptureButton
1265
+ progress={sideButtonProgress}
1266
+ disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
1267
+ appearance="light"
1268
+ onClick={triggerManualCapture}
1269
+ />
1270
+ )}
1271
+ {showSideGalleryButton && (
1272
+ <GalleryButton onClick={handlePickFromGallery} />
1273
+ )}
1274
+ </div>
1275
+ )}
1276
+
1277
+ {/* Floating capture status pill */}
1278
+ <div
1279
+ style={{
1280
+ position: 'absolute',
1281
+ left: '50%',
1282
+ bottom: shouldRotateUi ? 5 : 184,
1283
+ transform: 'translateX(-50%)',
1284
+ backgroundColor: 'rgba(35,35,35,0.95)',
1285
+ borderRadius: 14,
1286
+ border: '1px solid rgba(255,255,255,0.08)',
1287
+ padding: '9px 20px',
1288
+ minWidth: 220,
1289
+ maxWidth: 'calc(100% - 32px)',
1290
+ textAlign: 'center',
1291
+ boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
1292
+ pointerEvents: 'auto',
1293
+ }}
1294
+ >
1295
+ <span
1296
+ style={{
1297
+ color: '#fff',
1298
+ fontSize: 16,
1299
+ fontWeight: 700,
1300
+ fontFamily: theme.fonts.base,
1301
+ letterSpacing: '-0.1px',
1302
+ }}
1303
+ >
1304
+ {visibleFeedback}
1305
+ </span>
1306
+ </div>
1307
+ </div>
1308
+
1309
+ {/* Manual fallback button — only shown in portrait layout (not rotated).
1310
+ Matches the landscape side-button styling: bare CaptureButton with
1311
+ no pill background. The CaptureButton is centered absolutely; the
1312
+ gallery button is anchored to its right so the shutter stays on
1313
+ the horizontal centerline regardless of which controls are shown. */}
1314
+ {!shouldRotateUi &&
1315
+ (showManualCaptureControl || showBottomGalleryButton) &&
1316
+ !useSideManualCapture && (
1317
+ <>
1318
+ {showManualCaptureControl && (
1319
+ <div
1320
+ style={{
1321
+ position: 'absolute',
1322
+ left: '50%',
1323
+ bottom: 60,
1324
+ transform: 'translateX(-50%)',
1325
+ zIndex: 12,
1326
+ }}
1327
+ >
1328
+ <CaptureButton
1329
+ progress={
1330
+ visibleComplianceState === COMPLIANCE_STATES.STABLE
1331
+ ? captureProgress
1332
+ : 0
1333
+ }
1334
+ disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
1335
+ appearance="light"
1336
+ onClick={triggerManualCapture}
1337
+ />
1338
+ </div>
1339
+ )}
1340
+ {showBottomGalleryButton && (
1341
+ <div
1342
+ style={{
1343
+ position: 'absolute',
1344
+ // Shutter is 72 px wide and centered; gallery sits to its
1345
+ // left at 36 px half-width + 24 px gap so the shutter
1346
+ // stays exactly on the centerline.
1347
+ left: showManualCaptureControl
1348
+ ? 'calc(50% - 36px - 24px)'
1349
+ : '50%',
1350
+ bottom: 60,
1351
+ transform: showManualCaptureControl
1352
+ ? 'translateX(-100%)'
1353
+ : 'translateX(-50%)',
1354
+ zIndex: 12,
1355
+ }}
1356
+ >
1357
+ <GalleryButton onClick={handlePickFromGallery} />
1358
+ </div>
1359
+ )}
1360
+ {captureMode === 'autoCaptureOnly' && cvLoadFailed && (
1361
+ <p
1362
+ style={{
1363
+ position: 'absolute',
1364
+ left: '50%',
1365
+ bottom: 28,
1366
+ transform: 'translateX(-50%)',
1367
+ zIndex: 12,
1368
+ color: theme.colors.error,
1369
+ fontSize: '0.8rem',
1370
+ textAlign: 'center',
1371
+ margin: 0,
1372
+ }}
1373
+ >
1374
+ {translate('document.autoCapture.error.cvLoadFailed')}
1375
+ </p>
1376
+ )}
1377
+ </>
1378
+ )}
1379
+ </div>
1380
+
1381
+ {/* Tuning panel (debug mode only) */}
1382
+ {/* Build-time gate → tree-shaken in production (see note above). */}
1383
+ {__SMILE_DEBUG__ && showDebug && (
1384
+ <TuningPanel
1385
+ settings={settings}
1386
+ updateSetting={updateSetting}
1387
+ debugInfo={debugInfo}
1388
+ />
1389
+ )}
1390
+ </div>
1391
+ );
1392
+ };
1393
+
1394
+ /**
1395
+ * `<document-auto-capture>` — auto-capture document scanner.
1396
+ *
1397
+ * Drop-in replacement for the legacy `<document-capture>` element used by
1398
+ * `DocumentCaptureScreens`. The orchestrator renders both front + back
1399
+ * instances simultaneously and toggles their `hidden` attribute, so this
1400
+ * wrapper observes the host's `hidden` attribute and only mounts the heavy
1401
+ * inner component (camera + OpenCV detection loop) while it's visible.
1402
+ */
1403
+ const DocumentAutoCapture: FunctionComponent<Props> = (props) => {
1404
+ const rootRef = useRef<HTMLDivElement>(null);
1405
+ const [isActive, setIsActive] = useState(false);
1406
+
1407
+ useLayoutEffect(() => {
1408
+ const root = rootRef.current?.getRootNode();
1409
+ const host =
1410
+ root && root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
1411
+
1412
+ if (!host) {
1413
+ // Not inside a shadow root (e.g. test harness) — always render.
1414
+ setIsActive(true);
1415
+ return undefined;
1416
+ }
1417
+
1418
+ const update = () => setIsActive(!host.hasAttribute('hidden'));
1419
+ update();
1420
+
1421
+ const obs = new MutationObserver(update);
1422
+ obs.observe(host, { attributes: true, attributeFilter: ['hidden'] });
1423
+ return () => obs.disconnect();
1424
+ }, []);
1425
+
1426
+ return (
1427
+ <div
1428
+ ref={rootRef}
1429
+ style={{ width: '100%', height: '100%', display: 'contents' }}
1430
+ >
1431
+ {isActive ? <DocumentAutoCaptureInner {...props} /> : null}
1432
+ </div>
1433
+ );
1434
+ };
1435
+
1436
+ if (
1437
+ typeof customElements !== 'undefined' &&
1438
+ !customElements.get('document-auto-capture')
1439
+ ) {
1440
+ register(
1441
+ DocumentAutoCapture,
1442
+ 'document-auto-capture',
1443
+ [
1444
+ 'document-type',
1445
+ 'auto-capture',
1446
+ 'auto-capture-timeout',
1447
+ 'side-of-id',
1448
+ 'show-navigation',
1449
+ 'allow-gallery-upload',
1450
+ 'document-capture-modes',
1451
+ 'sync-roi-to-guide',
1452
+ 'title',
1453
+ ],
1454
+ { shadow: true },
1455
+ );
1456
+ }
1457
+
1458
+ export default DocumentAutoCapture;