@arkyc/widget 1.0.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 (59) hide show
  1. package/README.md +85 -0
  2. package/dist/ArkycWidget.d.mts +50 -0
  3. package/dist/ArkycWidget.d.mts.map +1 -0
  4. package/dist/ArkycWidget.mjs +80 -0
  5. package/dist/ArkycWidget.mjs.map +1 -0
  6. package/dist/WidgetHandler.d.mts +24 -0
  7. package/dist/WidgetHandler.d.mts.map +1 -0
  8. package/dist/WidgetHandler.mjs +28 -0
  9. package/dist/WidgetHandler.mjs.map +1 -0
  10. package/dist/_virtual/_virtual_arkyc-theme-css.mjs +4 -0
  11. package/dist/arkyc-widget.iife.global.js +670 -0
  12. package/dist/arkyc-widget.iife.global.js.map +1 -0
  13. package/dist/capture.d.mts +73 -0
  14. package/dist/capture.d.mts.map +1 -0
  15. package/dist/capture.mjs +126 -0
  16. package/dist/capture.mjs.map +1 -0
  17. package/dist/client.d.mts +152 -0
  18. package/dist/client.d.mts.map +1 -0
  19. package/dist/client.mjs +120 -0
  20. package/dist/client.mjs.map +1 -0
  21. package/dist/controller.d.mts +126 -0
  22. package/dist/controller.d.mts.map +1 -0
  23. package/dist/controller.mjs +582 -0
  24. package/dist/controller.mjs.map +1 -0
  25. package/dist/countries.mjs +967 -0
  26. package/dist/countries.mjs.map +1 -0
  27. package/dist/device.mjs +17 -0
  28. package/dist/device.mjs.map +1 -0
  29. package/dist/document.d.mts +108 -0
  30. package/dist/document.d.mts.map +1 -0
  31. package/dist/document.mjs +227 -0
  32. package/dist/document.mjs.map +1 -0
  33. package/dist/face.d.mts +82 -0
  34. package/dist/face.d.mts.map +1 -0
  35. package/dist/face.mjs +230 -0
  36. package/dist/face.mjs.map +1 -0
  37. package/dist/flow.d.mts +74 -0
  38. package/dist/flow.d.mts.map +1 -0
  39. package/dist/flow.mjs +132 -0
  40. package/dist/flow.mjs.map +1 -0
  41. package/dist/index.d.mts +19 -0
  42. package/dist/index.d.mts.map +1 -0
  43. package/dist/index.mjs +16 -0
  44. package/dist/index.mjs.map +1 -0
  45. package/dist/qr.mjs +22 -0
  46. package/dist/qr.mjs.map +1 -0
  47. package/dist/realtime.d.mts +29 -0
  48. package/dist/realtime.d.mts.map +1 -0
  49. package/dist/realtime.mjs +107 -0
  50. package/dist/realtime.mjs.map +1 -0
  51. package/dist/theme.d.mts +42 -0
  52. package/dist/theme.d.mts.map +1 -0
  53. package/dist/theme.mjs +77 -0
  54. package/dist/theme.mjs.map +1 -0
  55. package/dist/types.d.mts +153 -0
  56. package/dist/types.d.mts.map +1 -0
  57. package/dist/ui.mjs +931 -0
  58. package/dist/ui.mjs.map +1 -0
  59. package/package.json +43 -0
package/dist/ui.mjs ADDED
@@ -0,0 +1,931 @@
1
+ import { Theme } from "./theme.mjs";
2
+ import { Camera } from "./capture.mjs";
3
+ import { DEFAULT_TUNING, createDefaultFaceAnalyzer, isSelfieReady, makeChallengeDetector } from "./face.mjs";
4
+ import { DEFAULT_DOCUMENT_TUNING, createDefaultDocumentAnalyzer, documentGuidance } from "./document.mjs";
5
+ import { countries } from "./countries.mjs";
6
+ //#region src/ui.ts
7
+ const DOCUMENT_LABELS = {
8
+ passport: "Passport",
9
+ id_card: "ID Card",
10
+ drivers_license: "Driver's License",
11
+ residence_permit: "Residence Permit"
12
+ };
13
+ const CHALLENGE_LABELS = {
14
+ turn_left: "Slowly turn your head to the left, and hold",
15
+ turn_right: "Slowly turn your head to the right, and hold",
16
+ blink: "Blink slowly",
17
+ smile: "Smile, and hold it",
18
+ nod: "Slowly nod your head",
19
+ move_closer: "Slowly move closer to the camera"
20
+ };
21
+ /** Fallback notice for an expired/unknown terminal session. */
22
+ const EXPIRED_NOTICE = {
23
+ cls: "warn",
24
+ icon: "⏳",
25
+ title: "Link expired",
26
+ copy: "This verification link has expired."
27
+ };
28
+ /** Close-only notice copy for a session that was already terminal on load. */
29
+ const TERMINAL_NOTICE = {
30
+ approved: {
31
+ cls: "ok",
32
+ icon: "✓",
33
+ title: "Already complete",
34
+ copy: "This verification has already been completed."
35
+ },
36
+ rejected: {
37
+ cls: "ok",
38
+ icon: "✓",
39
+ title: "Already complete",
40
+ copy: "This verification has already been completed."
41
+ },
42
+ requires_review: {
43
+ cls: "ok",
44
+ icon: "✓",
45
+ title: "Already complete",
46
+ copy: "This verification has already been submitted and is being reviewed."
47
+ },
48
+ expired: EXPIRED_NOTICE,
49
+ cancelled: {
50
+ cls: "warn",
51
+ icon: "⏳",
52
+ title: "Cancelled",
53
+ copy: "This verification was cancelled."
54
+ }
55
+ };
56
+ const SVG = (body) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
57
+ /** Per-challenge directional cue icons, animated via CSS over the preview. */
58
+ const CUE_ICONS = {
59
+ turn_right: SVG("<path d=\"M5 12h14M13 6l6 6-6 6\"/>"),
60
+ turn_left: SVG("<path d=\"M19 12H5M11 6l-6 6 6 6\"/>"),
61
+ nod: SVG("<path d=\"M12 5v14M6 13l6 6 6-6\"/>"),
62
+ move_closer: SVG("<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v3M12 19v3M2 12h3M19 12h3\"/>"),
63
+ blink: SVG("<path d=\"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
64
+ smile: SVG("<path d=\"M8 14s1.5 2 4 2 4-2 4-2\"/><path d=\"M9 9h.01M15 9h.01\"/>")
65
+ };
66
+ const CHECK_ICON = SVG("<path d=\"M20 6 9 17l-5-5\"/>");
67
+ /** Larger line-art illustration used to dress the informative / lead-in screens. */
68
+ const ART = (body) => `<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
69
+ /** Per-screen decorative illustrations, keyed by step (plus a welcome hero). */
70
+ const STEP_ART = {
71
+ welcome: ART("<path d=\"M32 7 52 14v14c0 12.5-8.2 21.6-20 25.6C20.2 49.6 12 40.5 12 28V14z\"/><path d=\"M23 30.5l6.2 6.2L41 24.5\"/>"),
72
+ front_capture: ART("<rect x=\"7\" y=\"15\" width=\"50\" height=\"34\" rx=\"5\"/><circle cx=\"22\" cy=\"28\" r=\"5\"/><path d=\"M14 42c1-5 5-7 8-7s7 2 8 7\"/><path d=\"M38 27h12M38 34h12M38 41h8\"/>"),
73
+ back_capture: ART("<rect x=\"7\" y=\"15\" width=\"50\" height=\"34\" rx=\"5\"/><rect x=\"7\" y=\"20\" width=\"50\" height=\"6\" fill=\"currentColor\" stroke=\"none\" opacity=\".18\"/><path d=\"M14 34h36M14 41h24\"/>"),
74
+ selfie_capture: ART("<path d=\"M10 20v-6a4 4 0 0 1 4-4h6\"/><path d=\"M44 10h6a4 4 0 0 1 4 4v6\"/><path d=\"M54 44v6a4 4 0 0 1-4 4h-6\"/><path d=\"M20 54h-6a4 4 0 0 1-4-4v-6\"/><circle cx=\"32\" cy=\"27\" r=\"8\"/><path d=\"M19 50c0-7 6-11 13-11s13 4 13 11\"/>"),
75
+ active_liveness: ART("<circle cx=\"32\" cy=\"30\" r=\"13\"/><circle cx=\"27\" cy=\"27\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"37\" cy=\"27\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M26 34c2 2.5 10 2.5 12 0\"/><path d=\"M13 30H6M10 26l-4 4 4 4\"/><path d=\"M51 30h7M54 26l4 4-4 4\"/>")
76
+ };
77
+ /**
78
+ * Renders the widget's screens into a host element. DOM- and camera-injectable
79
+ * so it can run under a fake DOM in tests. The view owns capture-screen camera
80
+ * mechanics and raises only high-level events to the controller.
81
+ */
82
+ var WidgetView = class {
83
+ doc;
84
+ theme;
85
+ handlers;
86
+ root;
87
+ body;
88
+ footer;
89
+ /** The `<style>` carrying the theme's CSS vars; swapped on runtime re-brand. */
90
+ styleEl;
91
+ /** The header brand group (logo/name/title); rebuilt on runtime re-brand. */
92
+ brandEl;
93
+ camera;
94
+ analyzer;
95
+ tuning;
96
+ docAnalyzer;
97
+ docTuning;
98
+ /** Interval ids for live quality/recording timers, cleared on re-render. */
99
+ timers = [];
100
+ /** Extra teardown run on each re-render (cancels in-flight detection loops). */
101
+ cleanups = [];
102
+ constructor(doc, theme, handlers, nav = globalThis.navigator, analyzer = createDefaultFaceAnalyzer(), tuning = DEFAULT_TUNING, docAnalyzer = createDefaultDocumentAnalyzer(), docTuning = DEFAULT_DOCUMENT_TUNING) {
103
+ this.doc = doc;
104
+ this.theme = theme;
105
+ this.handlers = handlers;
106
+ this.camera = new Camera(doc, nav);
107
+ this.analyzer = analyzer;
108
+ this.tuning = tuning;
109
+ this.docAnalyzer = docAnalyzer;
110
+ this.docTuning = docTuning;
111
+ this.root = this.el("div", { class: "arkyc-root" });
112
+ this.styleEl = this.el("style", { text: theme.stylesheet() });
113
+ this.root.appendChild(this.styleEl);
114
+ const card = this.el("div", { class: "arkyc-card" });
115
+ const header = this.el("div", { class: "arkyc-header" });
116
+ this.brandEl = this.el("div", { class: "arkyc-brand" });
117
+ this.fillBrand();
118
+ header.appendChild(this.brandEl);
119
+ const close = this.el("button", {
120
+ class: "arkyc-close",
121
+ html: "&times;",
122
+ "aria-label": "Close"
123
+ });
124
+ close.addEventListener("click", () => this.handlers.onClose());
125
+ header.appendChild(close);
126
+ this.body = this.el("div", { class: "arkyc-body" });
127
+ this.footer = this.el("div", { class: "arkyc-footer" });
128
+ card.appendChild(header);
129
+ card.appendChild(this.body);
130
+ card.appendChild(this.footer);
131
+ this.root.appendChild(card);
132
+ }
133
+ /**
134
+ * Populate the header brand group from the current theme: the project logo
135
+ * and/or name when branding is shown, otherwise a neutral title.
136
+ */
137
+ fillBrand() {
138
+ this.clear(this.brandEl);
139
+ const lastTenMins = Date.now() - Math.floor(Math.random() * (600 * 1e3));
140
+ if (this.theme.showBranding && (this.theme.logoUrl || this.theme.name)) {
141
+ if (this.theme.logoUrl) this.brandEl.appendChild(this.el("img", {
142
+ class: "arkyc-logo",
143
+ src: this.theme.logoUrl + "?stamp=" + lastTenMins
144
+ }));
145
+ if (this.theme.name) this.brandEl.appendChild(this.el("span", {
146
+ class: "arkyc-brand-name",
147
+ text: this.theme.name
148
+ }));
149
+ } else this.brandEl.appendChild(this.el("p", {
150
+ class: "arkyc-title",
151
+ text: "Verify your identity"
152
+ }));
153
+ }
154
+ /**
155
+ * Re-theme the widget at runtime from project branding (resolved server-side):
156
+ * swap the CSS-variable stylesheet (colours/radius) and rebuild the header
157
+ * brand (logo/name). Cascades to every already-rendered element via the vars.
158
+ */
159
+ applyBranding(branding) {
160
+ this.theme = new Theme(branding);
161
+ this.styleEl.textContent = this.theme.stylesheet();
162
+ this.fillBrand();
163
+ }
164
+ /**
165
+ * The widget's root element — append this to the overlay / container.
166
+ */
167
+ get element() {
168
+ return this.root;
169
+ }
170
+ /**
171
+ * Whether live camera capture is available (drives the active-liveness branch).
172
+ */
173
+ get cameraSupported() {
174
+ return this.camera.supported;
175
+ }
176
+ /**
177
+ * Release any active camera stream and clear live timers.
178
+ */
179
+ destroy() {
180
+ this.timers.forEach((id) => clearInterval(id));
181
+ this.timers = [];
182
+ this.cleanups.forEach((fn) => fn());
183
+ this.cleanups = [];
184
+ this.camera.stop();
185
+ }
186
+ /**
187
+ * Render the screen for the given state.
188
+ *
189
+ * @param state
190
+ * @returns
191
+ */
192
+ render(state) {
193
+ this.destroy();
194
+ this.clear(this.body);
195
+ this.clear(this.footer);
196
+ this.root.classList.remove("arkyc-handoff");
197
+ switch (state.step) {
198
+ case "welcome": return this.renderWelcome(state.handoffAvailable);
199
+ case "document_selection": return this.renderDocumentSelection();
200
+ case "front_capture": return this.renderCapture("Front of document", "environment", state.allowSkip, false, state.strictCapture);
201
+ case "back_capture": return this.renderCapture("Back of document", "environment", state.allowSkip, false, state.strictCapture);
202
+ case "selfie_capture": return this.renderCapture("Take a selfie", "user", state.allowSkip, true, state.strictCapture);
203
+ case "active_liveness": return this.renderActiveLiveness(state.livenessChallenges ?? [], state.allowSkip, state.requireLiveCamera);
204
+ case "ocr_processing": return this.renderProcessing("Reading your document…");
205
+ case "passive_liveness": return this.renderProcessing("Checking liveness…");
206
+ case "face_match": return this.renderProcessing("Matching your face…");
207
+ case "processing": return this.renderProcessing(state.statusLabel ?? "Finalising verification…");
208
+ case "result": return this.renderResult(state.decision ?? null, state.errorMessage, {
209
+ terminal: state.terminalNotice,
210
+ status: state.status
211
+ });
212
+ }
213
+ }
214
+ /** Prepend a decorative illustration for the given screen, when one exists. */
215
+ appendArt(key) {
216
+ const art = STEP_ART[key];
217
+ if (art) this.body.appendChild(this.el("div", {
218
+ class: "arkyc-illus",
219
+ html: art
220
+ }));
221
+ }
222
+ renderWelcome(handoffAvailable) {
223
+ this.appendArt("welcome");
224
+ this.body.appendChild(this.el("h2", {
225
+ class: "arkyc-h",
226
+ text: "Verify your identity"
227
+ }));
228
+ this.body.appendChild(this.el("p", {
229
+ class: "arkyc-p",
230
+ text: "You will need a government-issued ID and a moment to take a selfie. Your data is processed securely."
231
+ }));
232
+ this.footer.appendChild(this.button("Get started", () => this.handlers.onStart()));
233
+ if (handoffAvailable) this.footer.appendChild(this.button("Continue on your phone", () => this.handlers.onUsePhone(), "arkyc-btn-ghost"));
234
+ }
235
+ /** A neutral connecting screen shown while the widget bootstraps the session. */
236
+ renderLoading(label = "Loading…") {
237
+ this.destroy();
238
+ this.clear(this.body);
239
+ this.clear(this.footer);
240
+ this.root.classList.remove("arkyc-handoff");
241
+ this.renderProcessing(label);
242
+ }
243
+ /**
244
+ * An interstitial that tells the user what the next step is, with a single
245
+ * Continue button — so each step starts deliberately rather than the camera
246
+ * springing to life unannounced.
247
+ */
248
+ renderInstruction(step, title, body, cta, onContinue) {
249
+ this.destroy();
250
+ this.clear(this.body);
251
+ this.clear(this.footer);
252
+ this.root.classList.remove("arkyc-handoff");
253
+ this.appendArt(step);
254
+ this.body.appendChild(this.el("h2", {
255
+ class: "arkyc-h",
256
+ text: title
257
+ }));
258
+ this.body.appendChild(this.el("p", {
259
+ class: "arkyc-p",
260
+ text: body
261
+ }));
262
+ this.footer.appendChild(this.button(cta, onContinue));
263
+ }
264
+ /**
265
+ * Show the cross-device handoff QR: the user scans it to resume this same
266
+ * session on their phone. The widget then waits for the other device to finish.
267
+ * `allowContinueHere` offers an escape hatch to keep verifying on this device.
268
+ */
269
+ renderHandoff(qrSvg, allowContinueHere) {
270
+ this.destroy();
271
+ this.clear(this.body);
272
+ this.clear(this.footer);
273
+ this.root.classList.add("arkyc-handoff");
274
+ this.body.appendChild(this.el("h2", {
275
+ class: "arkyc-h",
276
+ text: "Continue on your phone"
277
+ }));
278
+ this.body.appendChild(this.el("p", {
279
+ class: "arkyc-p",
280
+ text: "Scan this code with your phone camera to finish verifying there."
281
+ }));
282
+ this.body.appendChild(this.el("div", {
283
+ class: "arkyc-qr",
284
+ html: qrSvg
285
+ }));
286
+ const waiting = this.el("div", { class: "arkyc-handoff-wait" });
287
+ waiting.appendChild(this.el("div", { class: "arkyc-spinner sm" }));
288
+ waiting.appendChild(this.el("span", { text: "Waiting for your phone…" }));
289
+ this.body.appendChild(waiting);
290
+ if (allowContinueHere) this.footer.appendChild(this.button("Continue on this device", () => this.handlers.onContinueHere(), "arkyc-btn-ghost"));
291
+ }
292
+ renderDocumentSelection() {
293
+ this.body.appendChild(this.el("h2", {
294
+ class: "arkyc-h",
295
+ text: "Select your document"
296
+ }));
297
+ const country = this.el("select", {
298
+ class: "arkyc-btn arkyc-btn-ghost arkyc-text-center",
299
+ name: "country",
300
+ autocomplete: "country",
301
+ "aria-label": "Country"
302
+ });
303
+ const placeholder = this.el("option", {
304
+ value: "",
305
+ selected: "selected"
306
+ });
307
+ placeholder.textContent = "Country";
308
+ country.appendChild(placeholder);
309
+ countries.forEach(({ name, iso2, flag }) => {
310
+ const option = this.el("option", { value: iso2 });
311
+ option.textContent = `${flag} ${name}`;
312
+ country.appendChild(option);
313
+ });
314
+ const choices = this.el("div", { class: "arkyc-choices" });
315
+ choices.appendChild(country);
316
+ Object.keys(DOCUMENT_LABELS).forEach((type) => {
317
+ const btn = this.button(DOCUMENT_LABELS[type], () => this.handlers.onDocumentSelected(type, (country.value || "").trim().toUpperCase()));
318
+ btn.classList.add("arkyc-btn-ghost");
319
+ choices.appendChild(btn);
320
+ });
321
+ this.body.appendChild(choices);
322
+ }
323
+ renderCapture(title, facing, allowSkip, selfie = false, strict = false) {
324
+ const strictDoc = strict && !selfie;
325
+ this.body.appendChild(this.el("h2", {
326
+ class: "arkyc-h",
327
+ text: title
328
+ }));
329
+ this.body.appendChild(this.el("p", {
330
+ class: "arkyc-p",
331
+ text: "Position it clearly in frame, then capture."
332
+ }));
333
+ const fileInput = this.el("input", {
334
+ type: "file",
335
+ accept: "image/*",
336
+ class: "arkyc-hidden"
337
+ });
338
+ fileInput.addEventListener("change", () => this.handlers.onImage(Camera.fileFromInput(fileInput)));
339
+ this.body.appendChild(fileInput);
340
+ if (this.camera.supported) {
341
+ const video = this.el("video", { class: `arkyc-preview${selfie ? " selfie" : ""}` });
342
+ const face = selfie ? this.buildFaceStage(video) : null;
343
+ const doc = selfie ? null : this.buildDocStage(video);
344
+ const mount = face?.stage ?? doc.stage;
345
+ this.body.appendChild(mount);
346
+ const hint = this.el("p", { class: "arkyc-p arkyc-hint" });
347
+ this.body.appendChild(hint);
348
+ const confirmCapture = (blob) => {
349
+ this.destroy();
350
+ if (blob) {
351
+ const url = URL.createObjectURL(blob);
352
+ const still = this.el("img", {
353
+ class: `arkyc-preview${selfie ? " selfie" : ""}`,
354
+ src: url
355
+ });
356
+ mount.replaceWith(still);
357
+ this.cleanups.push(() => URL.revokeObjectURL(url));
358
+ } else mount.classList.add("arkyc-hidden");
359
+ this.clear(this.footer);
360
+ hint.textContent = "✓ Captured";
361
+ this.footer.appendChild(this.button("Continue", () => this.handlers.onImage(blob)));
362
+ const retake = this.button("Retake", () => {
363
+ this.destroy();
364
+ this.clear(this.body);
365
+ this.clear(this.footer);
366
+ this.renderCapture(title, facing, allowSkip, selfie, strict);
367
+ });
368
+ retake.classList.add("arkyc-btn-ghost");
369
+ this.footer.appendChild(retake);
370
+ };
371
+ const onCapture = () => void this.camera.grabFrame(video).then(confirmCapture);
372
+ this.camera.start(video, facing).catch(() => {
373
+ mount.classList.add("arkyc-hidden");
374
+ if (strictDoc) {
375
+ hint.textContent = "Camera access is required to scan your document. Please allow access and try again.";
376
+ return;
377
+ }
378
+ fileInput.click();
379
+ });
380
+ if (selfie) this.runSelfieAutoCapture(video, hint, onCapture, face);
381
+ else {
382
+ this.runDocumentAutoCapture(video, hint, doc, strictDoc, onCapture);
383
+ if (!strictDoc) this.footer.appendChild(this.button("Capture", onCapture));
384
+ }
385
+ } else if (strictDoc) {
386
+ this.body.appendChild(this.el("div", {
387
+ class: "arkyc-badge err",
388
+ html: "!"
389
+ }));
390
+ this.body.appendChild(this.el("p", {
391
+ class: "arkyc-p",
392
+ text: "This step needs a working camera to scan your document. Please retry on a device with a camera."
393
+ }));
394
+ } else {
395
+ const upload = this.button("Upload photo", () => fileInput.click());
396
+ this.footer.appendChild(upload);
397
+ }
398
+ if (allowSkip) {
399
+ const skip = this.button("Skip (demo)", () => this.handlers.onImage(null));
400
+ skip.classList.add("arkyc-btn-ghost");
401
+ this.footer.appendChild(skip);
402
+ }
403
+ }
404
+ /**
405
+ * Run `cb` once the live camera feed appears (the video has frames). The
406
+ * detection model may still be downloading at that point, so callers use this
407
+ * to replace the "starting camera" hint with a neutral one.
408
+ *
409
+ * @param video
410
+ * @param cb
411
+ */
412
+ onCameraLive(video, cb) {
413
+ if ((video.readyState ?? 0) >= 2) {
414
+ cb();
415
+ return;
416
+ }
417
+ video.addEventListener("playing", cb, { once: true });
418
+ }
419
+ /**
420
+ * Document capture: detect a real, well-framed, in-focus document (edge
421
+ * projection) and auto-grab only once it's been held steady. Falls back to the
422
+ * brightness/glare heuristic when the detector can't run.
423
+ *
424
+ * @param video
425
+ * @returns
426
+ */
427
+ runDocumentAutoCapture(video, hint, doc, strict, capture) {
428
+ const onNoDetector = () => {
429
+ if (strict) {
430
+ hint.textContent = "Document scanning isn’t available on this device.";
431
+ return;
432
+ }
433
+ this.runDocumentBrightnessCapture(video, hint, doc, capture);
434
+ };
435
+ const analyzer = this.docAnalyzer;
436
+ if (!analyzer) return onNoDetector();
437
+ hint.textContent = "Starting camera…";
438
+ let cancelled = false;
439
+ let modelReady = false;
440
+ this.cleanups.push(() => {
441
+ cancelled = true;
442
+ });
443
+ this.onCameraLive(video, () => {
444
+ if (!cancelled && !modelReady) hint.textContent = "Getting ready…";
445
+ });
446
+ analyzer.ready().then((ok) => {
447
+ modelReady = true;
448
+ if (cancelled) return;
449
+ if (!ok) {
450
+ onNoDetector();
451
+ return;
452
+ }
453
+ let goodStreak = 0;
454
+ let fillEma = null;
455
+ let edgeEma = null;
456
+ const ema = (prev, next) => prev == null ? next : prev * .6 + next * .4;
457
+ const timer = setInterval(() => {
458
+ const sample = analyzer.analyze(video);
459
+ if (!sample) return;
460
+ fillEma = ema(fillEma, sample.fill);
461
+ edgeEma = ema(edgeEma, sample.edgeStrength);
462
+ const { ready, hint: message } = documentGuidance({
463
+ ...sample,
464
+ fill: fillEma,
465
+ edgeStrength: edgeEma
466
+ }, this.docTuning);
467
+ hint.textContent = message;
468
+ doc.setFrame(sample.present ? sample.rect : null);
469
+ doc.setQuality(ready ? "good" : sample.present ? "wait" : "bad");
470
+ goodStreak = ready ? goodStreak + 1 : Math.max(0, goodStreak - 1);
471
+ if (goodStreak >= this.docTuning.hold) {
472
+ clearInterval(timer);
473
+ doc.setQuality("good");
474
+ capture();
475
+ }
476
+ }, 250);
477
+ this.timers.push(timer);
478
+ });
479
+ }
480
+ /**
481
+ * Coarse fallback: brightness/glare heuristic, used when detection can't run.
482
+ *
483
+ * @param video
484
+ * @param hint
485
+ * @param doc
486
+ */
487
+ runDocumentBrightnessCapture(video, hint, doc, capture) {
488
+ let goodStreak = 0;
489
+ const timer = setInterval(() => {
490
+ const quality = this.camera.sampleQuality(video);
491
+ if (!quality) return;
492
+ if (quality.tooDark) {
493
+ hint.textContent = "Too dark — find better lighting";
494
+ goodStreak = 0;
495
+ doc.setQuality("bad");
496
+ } else if (quality.glare) {
497
+ hint.textContent = "Reduce glare on the document";
498
+ goodStreak = 0;
499
+ doc.setQuality("bad");
500
+ } else {
501
+ hint.textContent = "Looks good — hold steady";
502
+ goodStreak += 1;
503
+ doc.setQuality("good");
504
+ }
505
+ if (goodStreak >= 5) {
506
+ clearInterval(timer);
507
+ capture();
508
+ }
509
+ }, 300);
510
+ this.timers.push(timer);
511
+ }
512
+ /**
513
+ * Selfie capture: when face detection is available, auto-grab once a centred
514
+ * face is held steady. Falls back to a brightness hint (manual capture) when
515
+ * the detector can't load (unsupported browser / offline / test host).
516
+ *
517
+ * @param video
518
+ * @param hint
519
+ * @param capture
520
+ * @returns
521
+ */
522
+ runSelfieAutoCapture(video, hint, capture, face) {
523
+ const analyzer = this.analyzer;
524
+ const addManualCapture = () => this.footer.appendChild(this.button("Capture", capture));
525
+ if (!analyzer) {
526
+ hint.textContent = "Center your face, then capture";
527
+ addManualCapture();
528
+ return;
529
+ }
530
+ hint.textContent = "Starting camera…";
531
+ let cancelled = false;
532
+ let modelReady = false;
533
+ this.cleanups.push(() => {
534
+ cancelled = true;
535
+ });
536
+ this.onCameraLive(video, () => {
537
+ if (!cancelled && !modelReady) hint.textContent = "Getting ready…";
538
+ });
539
+ analyzer.ready().then((ok) => {
540
+ modelReady = true;
541
+ if (cancelled) return;
542
+ if (!ok) {
543
+ hint.textContent = "Center your face, then capture";
544
+ addManualCapture();
545
+ return;
546
+ }
547
+ const need = 4;
548
+ let streak = 0;
549
+ const timer = setInterval(() => {
550
+ const sample = analyzer.analyze(video);
551
+ if (!sample || !sample.present) {
552
+ hint.textContent = "Position your face in the circle";
553
+ streak = 0;
554
+ face.setState("wait");
555
+ face.setProgress(0);
556
+ return;
557
+ }
558
+ if (isSelfieReady(sample, this.tuning)) {
559
+ hint.textContent = "Hold still…";
560
+ streak += 1;
561
+ face.setState("good");
562
+ face.setProgress(streak / need);
563
+ } else {
564
+ hint.textContent = sample.scale <= this.tuning.selfieMinScale ? "Move a little closer" : "Center your face";
565
+ streak = 0;
566
+ face.setState("wait");
567
+ face.setProgress(0);
568
+ }
569
+ if (streak >= need) {
570
+ clearInterval(timer);
571
+ face.setState("done");
572
+ face.setProgress(1);
573
+ capture();
574
+ }
575
+ }, 180);
576
+ this.timers.push(timer);
577
+ });
578
+ }
579
+ /**
580
+ * Guided active-liveness screen: live front-camera preview, a recorded video,
581
+ * and a sequence of challenge prompts the user advances through. The performed
582
+ * sequence (the prompts shown, in order) is submitted for the driver to verify.
583
+ *
584
+ * @param video
585
+ * @returns
586
+ */
587
+ renderActiveLiveness(challenges, allowSkip, requireLiveCamera) {
588
+ this.body.appendChild(this.el("h2", {
589
+ class: "arkyc-h",
590
+ text: "Liveness check"
591
+ }));
592
+ const prompt = this.el("p", {
593
+ class: "arkyc-p",
594
+ text: "Follow the on-screen prompts. Keep your face centred in the circle."
595
+ });
596
+ this.body.appendChild(prompt);
597
+ if (!this.camera.supported || !this.camera.canRecord) {
598
+ if (requireLiveCamera) {
599
+ this.body.appendChild(this.el("div", {
600
+ class: "arkyc-badge err",
601
+ html: "!"
602
+ }));
603
+ prompt.textContent = "This check needs camera access on a supported device. Please retry on a device with a working camera.";
604
+ if (allowSkip) {
605
+ const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
606
+ skip.classList.add("arkyc-btn-ghost");
607
+ this.footer.appendChild(skip);
608
+ }
609
+ return;
610
+ }
611
+ const finish = this.button("I performed the steps", () => this.handlers.onActiveLiveness(null, challenges));
612
+ this.footer.appendChild(finish);
613
+ if (allowSkip) {
614
+ const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
615
+ skip.classList.add("arkyc-btn-ghost");
616
+ this.footer.appendChild(skip);
617
+ }
618
+ return;
619
+ }
620
+ const video = this.el("video", { class: "arkyc-preview selfie" });
621
+ const face = this.buildFaceStage(video);
622
+ const dots = this.buildDots(challenges.length);
623
+ if (challenges.length > 1) this.body.appendChild(dots.el);
624
+ this.body.appendChild(face.stage);
625
+ let recording = null;
626
+ let index = 0;
627
+ const performed = [];
628
+ let detector = makeChallengeDetector(challenges[0] ?? "blink", this.tuning);
629
+ const showPrompt = (done = false) => {
630
+ const challenge = challenges[index];
631
+ if (!challenge) {
632
+ prompt.textContent = "Hold still…";
633
+ return;
634
+ }
635
+ prompt.textContent = done ? `✓ ${CHALLENGE_LABELS[challenge]}` : `Step ${index + 1} of ${challenges.length}: ${CHALLENGE_LABELS[challenge]}`;
636
+ };
637
+ const armChallenge = () => {
638
+ face.setCue(challenges[index] ?? null);
639
+ face.setState("wait");
640
+ face.setProgress(0);
641
+ dots.setActive(index);
642
+ };
643
+ const finish = () => {
644
+ advance.setAttribute("disabled", "true");
645
+ this.camera.grabFrame(video).then((selfie) => Promise.resolve(recording?.stop() ?? Promise.resolve(null)).then((blob) => this.handlers.onActiveLiveness(blob, performed, selfie)));
646
+ };
647
+ const advanceStep = () => {
648
+ if (index >= challenges.length) return;
649
+ const current = challenges[index];
650
+ if (current) performed.push(current);
651
+ dots.markDone(index);
652
+ index += 1;
653
+ if (index >= challenges.length) {
654
+ finish();
655
+ return;
656
+ }
657
+ detector = makeChallengeDetector(challenges[index], this.tuning);
658
+ showPrompt();
659
+ armChallenge();
660
+ if (index === challenges.length - 1) advance.textContent = "Finish";
661
+ };
662
+ const advance = this.button("Next", () => advanceStep());
663
+ if (challenges.length <= 1) advance.textContent = "Finish";
664
+ let manualShown = false;
665
+ const showManualAdvance = () => {
666
+ if (manualShown) return;
667
+ manualShown = true;
668
+ this.footer.appendChild(advance);
669
+ };
670
+ this.camera.start(video, "user").then((stream) => {
671
+ recording = this.camera.recordStart(stream);
672
+ showPrompt();
673
+ armChallenge();
674
+ const analyzer = this.analyzer;
675
+ if (!analyzer) {
676
+ showManualAdvance();
677
+ return;
678
+ }
679
+ let cancelled = false;
680
+ this.cleanups.push(() => {
681
+ cancelled = true;
682
+ });
683
+ analyzer.ready().then((ok) => {
684
+ if (cancelled) return;
685
+ if (!ok) {
686
+ showManualAdvance();
687
+ return;
688
+ }
689
+ let flashing = false;
690
+ const timer = setInterval(() => {
691
+ if (index >= challenges.length) {
692
+ clearInterval(timer);
693
+ return;
694
+ }
695
+ if (flashing) return;
696
+ const sample = analyzer.analyze(video);
697
+ if (!sample) return;
698
+ const hit = detector.feed(sample);
699
+ face.setProgress(detector.progress);
700
+ if (hit) {
701
+ face.setState("done");
702
+ showPrompt(true);
703
+ flashing = true;
704
+ const to = setTimeout(() => {
705
+ flashing = false;
706
+ advanceStep();
707
+ }, 650);
708
+ this.cleanups.push(() => clearTimeout(to));
709
+ } else face.setState(detector.progress > 0 ? "good" : "wait");
710
+ }, 160);
711
+ this.timers.push(timer);
712
+ });
713
+ }).catch(() => {
714
+ face.stage.classList.add("arkyc-hidden");
715
+ if (requireLiveCamera) {
716
+ prompt.textContent = "Camera access is required to continue. Please allow access and try again.";
717
+ return;
718
+ }
719
+ this.handlers.onActiveLiveness(null, challenges);
720
+ });
721
+ if (allowSkip) {
722
+ const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
723
+ skip.classList.add("arkyc-btn-ghost");
724
+ this.footer.appendChild(skip);
725
+ }
726
+ }
727
+ /**
728
+ * Build the circular selfie/liveness preview overlay: an SVG progress ring, a
729
+ * gesture-cue layer, and a success checkmark — driven via the returned handles.
730
+ *
731
+ * @param video
732
+ * @returns
733
+ */
734
+ buildFaceStage(video) {
735
+ const stage = this.el("div", { class: "arkyc-stage" });
736
+ stage.setAttribute("data-state", "wait");
737
+ stage.appendChild(video);
738
+ const ring = this.el("div", {
739
+ class: "arkyc-ring",
740
+ html: "<svg viewBox=\"0 0 100 100\"><circle class=\"arkyc-ring-track\" cx=\"50\" cy=\"50\" r=\"46\"/><circle class=\"arkyc-ring-arc\" cx=\"50\" cy=\"50\" r=\"46\"/></svg>"
741
+ });
742
+ stage.appendChild(ring);
743
+ const cue = this.el("div", { class: "arkyc-cue" });
744
+ stage.appendChild(cue);
745
+ stage.appendChild(this.el("div", {
746
+ class: "arkyc-check",
747
+ html: CHECK_ICON
748
+ }));
749
+ const arc = ring.querySelector(".arkyc-ring-arc");
750
+ const circumference = 2 * Math.PI * 46;
751
+ if (arc) {
752
+ arc.style.strokeDasharray = String(circumference);
753
+ arc.style.strokeDashoffset = String(circumference);
754
+ }
755
+ return {
756
+ stage,
757
+ setProgress: (p) => {
758
+ if (arc) arc.style.strokeDashoffset = String(circumference * (1 - Math.max(0, Math.min(1, p))));
759
+ },
760
+ setState: (state) => stage.setAttribute("data-state", state),
761
+ setCue: (challenge) => {
762
+ cue.className = "arkyc-cue" + (challenge ? ` show arkyc-cue-${challenge}` : "");
763
+ cue.innerHTML = challenge ? CUE_ICONS[challenge] : "";
764
+ }
765
+ };
766
+ }
767
+ /**
768
+ * Build the liveness step-progress dots (one per challenge).
769
+ *
770
+ * @param video
771
+ * @param hint
772
+ * @param doc
773
+ */
774
+ buildDots(count) {
775
+ const el = this.el("div", { class: "arkyc-dots" });
776
+ const dots = [];
777
+ for (let i = 0; i < count; i += 1) {
778
+ const dot = this.el("div", { class: "arkyc-dot" });
779
+ dots.push(dot);
780
+ el.appendChild(dot);
781
+ }
782
+ return {
783
+ el,
784
+ setActive: (index) => dots.forEach((d, i) => d.classList.toggle("active", i === index && !d.classList.contains("done"))),
785
+ markDone: (index) => {
786
+ const d = dots[index];
787
+ if (d) {
788
+ d.classList.remove("active");
789
+ d.classList.add("done");
790
+ }
791
+ }
792
+ };
793
+ }
794
+ /**
795
+ * Build the document preview overlay: a fixed card-shaped alignment guide
796
+ * (corner brackets + border) with an animated scan line. The guide is a target,
797
+ * not a tracker — it stays put so the user aligns the card to it; detection only
798
+ * tints it for quality and brightens it once a document is in view.
799
+ *
800
+ * @param video
801
+ * @returns
802
+ */
803
+ buildDocStage(video) {
804
+ const stage = this.el("div", { class: "arkyc-doc" });
805
+ stage.setAttribute("data-q", "wait");
806
+ stage.appendChild(video);
807
+ const frame = this.el("div", { class: "arkyc-doc-frame" });
808
+ for (const corner of [
809
+ "tl",
810
+ "tr",
811
+ "bl",
812
+ "br"
813
+ ]) frame.appendChild(this.el("span", { class: `arkyc-corner ${corner}` }));
814
+ frame.appendChild(this.el("div", { class: "arkyc-scan" }));
815
+ stage.appendChild(frame);
816
+ return {
817
+ stage,
818
+ setQuality: (quality) => stage.setAttribute("data-q", quality),
819
+ setFrame: (rect) => stage.setAttribute("data-detected", rect ? "true" : "false")
820
+ };
821
+ }
822
+ renderProcessing(label) {
823
+ this.body.appendChild(this.el("div", { class: "arkyc-spinner" }));
824
+ this.body.appendChild(this.el("p", {
825
+ class: "arkyc-p",
826
+ text: label
827
+ }));
828
+ }
829
+ renderResult(decision, errorMessage, opts = {}) {
830
+ if (errorMessage) {
831
+ this.body.appendChild(this.el("div", {
832
+ class: "arkyc-badge err",
833
+ html: "!"
834
+ }));
835
+ this.body.appendChild(this.el("h2", {
836
+ class: "arkyc-h",
837
+ text: "Something went wrong"
838
+ }));
839
+ this.body.appendChild(this.el("p", {
840
+ class: "arkyc-p",
841
+ text: errorMessage
842
+ }));
843
+ } else if (opts.terminal) {
844
+ const n = TERMINAL_NOTICE[opts.status ?? "expired"] ?? EXPIRED_NOTICE;
845
+ this.body.appendChild(this.el("div", {
846
+ class: `arkyc-badge ${n.cls}`,
847
+ text: n.icon
848
+ }));
849
+ this.body.appendChild(this.el("h2", {
850
+ class: "arkyc-h",
851
+ text: n.title
852
+ }));
853
+ this.body.appendChild(this.el("p", {
854
+ class: "arkyc-p",
855
+ text: n.copy
856
+ }));
857
+ this.footer.appendChild(this.button("Close", () => this.handlers.onClose()));
858
+ return;
859
+ } else {
860
+ const map = {
861
+ approved: {
862
+ cls: "ok",
863
+ icon: "✓",
864
+ title: "Verified",
865
+ copy: "Your identity has been verified."
866
+ },
867
+ requires_review: {
868
+ cls: "warn",
869
+ icon: "⏳",
870
+ title: "Under review",
871
+ copy: "Your verification is being reviewed. We will be in touch shortly."
872
+ },
873
+ rejected: {
874
+ cls: "err",
875
+ icon: "✕",
876
+ title: "Not verified",
877
+ copy: "We could not verify your identity. Please try again."
878
+ }
879
+ };
880
+ const r = map[decision ?? "requires_review"] ?? map.requires_review;
881
+ this.body.appendChild(this.el("div", {
882
+ class: `arkyc-badge ${r.cls}`,
883
+ text: r.icon
884
+ }));
885
+ this.body.appendChild(this.el("h2", {
886
+ class: "arkyc-h",
887
+ text: r.title
888
+ }));
889
+ this.body.appendChild(this.el("p", {
890
+ class: "arkyc-p",
891
+ text: r.copy
892
+ }));
893
+ }
894
+ this.footer.appendChild(this.button("Done", () => this.handlers.onAcknowledge()));
895
+ }
896
+ button(label, onClick, extraClass) {
897
+ const cls = extraClass ? `arkyc-btn ${extraClass}` : "arkyc-btn";
898
+ const btn = this.el("button", {
899
+ class: cls,
900
+ text: label
901
+ });
902
+ let fired = false;
903
+ btn.addEventListener("click", () => {
904
+ if (fired) return;
905
+ fired = true;
906
+ btn.setAttribute("disabled", "true");
907
+ btn.classList.add("arkyc-busy");
908
+ onClick();
909
+ });
910
+ return btn;
911
+ }
912
+ clear(node) {
913
+ while (node.firstChild) node.removeChild(node.firstChild);
914
+ }
915
+ el(tag, props = {}) {
916
+ const node = this.doc.createElement(tag);
917
+ for (const [key, value] of Object.entries(props)) {
918
+ if (value == null) continue;
919
+ if (key === "class") node.className = value;
920
+ else if (key === "text") node.textContent = value;
921
+ else if (key === "html") node.innerHTML = value;
922
+ else if (key === "value" || key === "src" || key === "type" || key === "accept" || key === "placeholder") node[key] = value;
923
+ else node.setAttribute(key, value);
924
+ }
925
+ return node;
926
+ }
927
+ };
928
+ //#endregion
929
+ export { WidgetView };
930
+
931
+ //# sourceMappingURL=ui.mjs.map