smartcomply-web-sdk 1.0.26 → 1.0.30

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.
@@ -1,48 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LivenessUI = void 0;
4
- /**
5
- * Icons for the 5 backend-supported challenge actions (UPPERCASE).
6
- */
7
4
  const ICONS = {
8
- BLINK: "\uD83D\uDE09",
9
- TURN_LEFT: "\u27A1", // ➡️ — user turns RIGHT (mirror correction)
10
- TURN_RIGHT: "\u2B05", // ⬅️ — user turns LEFT (mirror correction)
11
- TURN_HEAD: "\uD83D\uDE42",
12
- OPEN_MOUTH: "\uD83D\uDE2E",
5
+ BLINK: "👁",
6
+ TURN_LEFT: "↔",
7
+ TURN_RIGHT: "↔",
8
+ TURN_HEAD: "↔",
9
+ OPEN_MOUTH: "😮",
13
10
  };
14
- /**
15
- * Large animated SVG/HTML cues shown in the centre of the camera frame
16
- * so the user knows exactly what to do — like a real liveness test.
17
- */
18
11
  const ACTION_CUES = {
19
- BLINK: `
20
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
21
- <div style="font-size:64px;line-height:1;animation:sc-blink 1.2s ease-in-out infinite;">👁️</div>
22
- <div style="font-size:13px;font-weight:600;color:#fff;opacity:0.9;letter-spacing:0.5px;">BLINK YOUR EYES</div>
23
- </div>`,
24
- TURN_LEFT: `
25
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
26
- <div style="font-size:56px;line-height:1;animation:sc-slide-right 1s ease-in-out infinite;">➡️</div>
27
- <div style="font-size:13px;font-weight:600;color:#fff;opacity:0.9;letter-spacing:0.5px;">TURN HEAD RIGHT</div>
28
- </div>`,
29
- TURN_RIGHT: `
30
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
31
- <div style="font-size:56px;line-height:1;animation:sc-slide-left 1s ease-in-out infinite;">⬅️</div>
32
- <div style="font-size:13px;font-weight:600;color:#fff;opacity:0.9;letter-spacing:0.5px;">TURN HEAD LEFT</div>
33
- </div>`,
34
- TURN_HEAD: `
35
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
36
- <div style="font-size:56px;line-height:1;animation:sc-turn-head 1.4s ease-in-out infinite;">↔️</div>
37
- <div style="font-size:13px;font-weight:600;color:#fff;opacity:0.9;letter-spacing:0.5px;">TURN HEAD SIDE TO SIDE</div>
38
- </div>`,
39
- OPEN_MOUTH: `
40
- <div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
41
- <div style="font-size:64px;line-height:1;animation:sc-mouth 1.2s ease-in-out infinite;">😮</div>
42
- <div style="font-size:13px;font-weight:600;color:#fff;opacity:0.9;letter-spacing:0.5px;">OPEN YOUR MOUTH</div>
43
- </div>`,
12
+ BLINK: `<div style="font-size:52px;line-height:1;animation:sc-blink 1.2s ease-in-out infinite;">👁️</div>
13
+ <div style="font-size:12px;font-weight:600;color:#fff;margin-top:6px;letter-spacing:0.5px;">BLINK YOUR EYES</div>`,
14
+ TURN_LEFT: `<div style="font-size:52px;line-height:1;animation:sc-turn-head 1.4s ease-in-out infinite;">↔️</div>
15
+ <div style="font-size:12px;font-weight:600;color:#fff;margin-top:6px;letter-spacing:0.5px;">TURN HEAD SIDEWAYS</div>`,
16
+ TURN_RIGHT: `<div style="font-size:52px;line-height:1;animation:sc-turn-head 1.4s ease-in-out infinite;">↔️</div>
17
+ <div style="font-size:12px;font-weight:600;color:#fff;margin-top:6px;letter-spacing:0.5px;">TURN HEAD SIDEWAYS</div>`,
18
+ TURN_HEAD: `<div style="font-size:52px;line-height:1;animation:sc-turn-head 1.4s ease-in-out infinite;">↔️</div>
19
+ <div style="font-size:12px;font-weight:600;color:#fff;margin-top:6px;letter-spacing:0.5px;">TURN HEAD SIDE TO SIDE</div>`,
20
+ OPEN_MOUTH: `<div style="font-size:52px;line-height:1;animation:sc-mouth 1.2s ease-in-out infinite;">😮</div>
21
+ <div style="font-size:12px;font-weight:600;color:#fff;margin-top:6px;letter-spacing:0.5px;">OPEN YOUR MOUTH</div>`,
44
22
  };
45
- /** Keyframe CSS injected once */
46
23
  function injectLivenessKeyframes() {
47
24
  if (document.getElementById("sc-liveness-kf"))
48
25
  return;
@@ -51,37 +28,23 @@ function injectLivenessKeyframes() {
51
28
  s.textContent = `
52
29
  @keyframes sc-blink {
53
30
  0%,100% { transform:scaleY(1); }
54
- 40% { transform:scaleY(0.1); }
55
- 50% { transform:scaleY(1); }
56
- }
57
- @keyframes sc-slide-left {
58
- 0%,100% { transform:translateX(0); opacity:1; }
59
- 50% { transform:translateX(-14px); opacity:0.6; }
60
- }
61
- @keyframes sc-slide-right {
62
- 0%,100% { transform:translateX(0); opacity:1; }
63
- 50% { transform:translateX(14px); opacity:0.6; }
31
+ 40% { transform:scaleY(0.1); }
32
+ 50% { transform:scaleY(1); }
64
33
  }
65
34
  @keyframes sc-turn-head {
66
- 0%,100% { transform:translateX(0); }
67
- 25% { transform:translateX(-12px); }
68
- 75% { transform:translateX(12px); }
35
+ 0%,100% { transform:translateX(0); }
36
+ 25% { transform:translateX(-12px); }
37
+ 75% { transform:translateX(12px); }
69
38
  }
70
39
  @keyframes sc-mouth {
71
- 0%,100% { transform:scaleY(1); }
72
- 40% { transform:scaleY(1.25); }
73
- 60% { transform:scaleY(1); }
40
+ 0%,100% { transform:scaleY(1); }
41
+ 40% { transform:scaleY(1.25); }
42
+ 60% { transform:scaleY(1); }
74
43
  }
75
44
  @keyframes sc-pop-in {
76
45
  0% { transform:scale(0.7); opacity:0; }
77
46
  60% { transform:scale(1.05); }
78
- 100% { transform:scale(1); opacity:1; }
79
- }
80
- @keyframes sc-cue-fade {
81
- 0% { opacity:0; transform:translateY(8px); }
82
- 20% { opacity:1; transform:translateY(0); }
83
- 80% { opacity:1; transform:translateY(0); }
84
- 100% { opacity:0; transform:translateY(-8px); }
47
+ 100% { transform:scale(1); opacity:1; }
85
48
  }
86
49
  `;
87
50
  document.head.appendChild(s);
@@ -105,83 +68,117 @@ class LivenessUI {
105
68
  mount(container, challenge) {
106
69
  injectLivenessKeyframes();
107
70
  this.totalActions = challenge.actions.length;
71
+ // ── Root — two-column layout ──────────────────────────────────────
108
72
  this.root = document.createElement("div");
109
73
  this.root.style.cssText = `
110
- position:relative;width:100%;max-width:420px;margin:0 auto;
74
+ display:flex;flex-direction:row;width:100%;max-width:560px;margin:0 auto;
111
75
  background:#0a0a0a;border-radius:16px;overflow:hidden;
112
76
  font-family:system-ui,-apple-system,sans-serif;
113
77
  box-shadow:0 8px 32px rgba(0,0,0,0.4);
78
+ min-height:340px;
79
+ `;
80
+ // ── LEFT: Camera column ───────────────────────────────────────────
81
+ const camCol = document.createElement("div");
82
+ camCol.style.cssText = `
83
+ position:relative;flex:0 0 58%;
84
+ background:#000;overflow:hidden;
114
85
  `;
115
- // Video
116
86
  this.videoEl = document.createElement("video");
117
87
  this.videoEl.autoplay = true;
118
88
  this.videoEl.playsInline = true;
119
89
  this.videoEl.muted = true;
120
- this.videoEl.style.cssText =
121
- "width:100%;display:block;transform:scaleX(-1);aspect-ratio:3/4;object-fit:cover;";
122
- // Face guide — oval with confidence ring
90
+ this.videoEl.style.cssText = `
91
+ width:100%;height:100%;display:block;
92
+ transform:scaleX(-1);object-fit:cover;
93
+ min-height:320px;
94
+ `;
95
+ // Oval guide — centred in camera column
123
96
  const guideWrap = document.createElement("div");
124
97
  guideWrap.style.cssText = `
125
- position:absolute;top:8%;left:50%;transform:translateX(-50%);
126
- width:180px;height:240px;pointer-events:none;
98
+ position:absolute;top:50%;left:50%;
99
+ transform:translate(-50%,-55%);
100
+ width:140px;height:180px;pointer-events:none;
127
101
  `;
128
102
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
129
- svg.setAttribute("viewBox", "0 0 180 240");
103
+ svg.setAttribute("viewBox", "0 0 140 180");
130
104
  svg.style.cssText = "width:100%;height:100%;";
131
- // Background oval (guide)
132
105
  const bgOval = document.createElementNS("http://www.w3.org/2000/svg", "ellipse");
133
- bgOval.setAttribute("cx", "90");
134
- bgOval.setAttribute("cy", "120");
135
- bgOval.setAttribute("rx", "85");
136
- bgOval.setAttribute("ry", "115");
106
+ bgOval.setAttribute("cx", "70");
107
+ bgOval.setAttribute("cy", "90");
108
+ bgOval.setAttribute("rx", "64");
109
+ bgOval.setAttribute("ry", "86");
137
110
  bgOval.setAttribute("fill", "none");
138
- bgOval.setAttribute("stroke", "rgba(255,255,255,0.25)");
139
- bgOval.setAttribute("stroke-width", "2.5");
140
- bgOval.setAttribute("stroke-dasharray", "8 4");
141
- // Confidence progress ring
111
+ bgOval.setAttribute("stroke", "rgba(255,255,255,0.3)");
112
+ bgOval.setAttribute("stroke-width", "2");
113
+ bgOval.setAttribute("stroke-dasharray", "6 3");
142
114
  const confOval = document.createElementNS("http://www.w3.org/2000/svg", "ellipse");
143
- confOval.setAttribute("cx", "90");
144
- confOval.setAttribute("cy", "120");
145
- confOval.setAttribute("rx", "85");
146
- confOval.setAttribute("ry", "115");
115
+ confOval.setAttribute("cx", "70");
116
+ confOval.setAttribute("cy", "90");
117
+ confOval.setAttribute("rx", "64");
118
+ confOval.setAttribute("ry", "86");
147
119
  confOval.setAttribute("fill", "none");
148
120
  confOval.setAttribute("stroke", "#22c55e");
149
- confOval.setAttribute("stroke-width", "3.5");
150
- const circumference = 2 * Math.PI * Math.sqrt((85 * 85 + 115 * 115) / 2);
151
- confOval.setAttribute("stroke-dasharray", `${circumference}`);
152
- confOval.setAttribute("stroke-dashoffset", `${circumference}`);
153
- confOval.style.cssText =
154
- "transition:stroke-dashoffset 0.15s ease;transform:rotate(-90deg);transform-origin:90px 120px;";
121
+ confOval.setAttribute("stroke-width", "3");
122
+ const circ = 2 * Math.PI * Math.sqrt((64 * 64 + 86 * 86) / 2);
123
+ confOval.setAttribute("stroke-dasharray", `${circ}`);
124
+ confOval.setAttribute("stroke-dashoffset", `${circ}`);
125
+ confOval.style.cssText = "transition:stroke-dashoffset 0.15s ease;transform:rotate(-90deg);transform-origin:70px 90px;";
155
126
  this.confidenceRingEl = confOval;
156
127
  svg.appendChild(bgOval);
157
128
  svg.appendChild(confOval);
158
129
  guideWrap.appendChild(svg);
159
- // Top bar — instruction
160
- const topBar = document.createElement("div");
161
- topBar.style.cssText = `
130
+ // Instruction text — top of camera column
131
+ const camTop = document.createElement("div");
132
+ camTop.style.cssText = `
162
133
  position:absolute;top:0;left:0;right:0;
163
- background:linear-gradient(180deg,rgba(0,0,0,0.7) 0%,transparent 100%);
164
- padding:16px 16px 32px;
134
+ background:linear-gradient(180deg,rgba(0,0,0,0.8) 0%,transparent 100%);
135
+ padding:10px 12px 28px;
165
136
  `;
166
137
  this.instructionEl = document.createElement("div");
167
138
  this.instructionEl.style.cssText = `
168
- color:#fff;font-size:15px;font-weight:600;text-align:center;
169
- line-height:1.4;
139
+ color:#fff;font-size:13px;font-weight:600;text-align:center;line-height:1.4;
170
140
  `;
171
141
  this.instructionEl.textContent = challenge.instruction;
172
- topBar.appendChild(this.instructionEl);
173
- // Bottom panel
174
- const bottomPanel = document.createElement("div");
175
- bottomPanel.style.cssText = `
142
+ camTop.appendChild(this.instructionEl);
143
+ // Timer — bottom of camera column
144
+ const camBottom = document.createElement("div");
145
+ camBottom.style.cssText = `
176
146
  position:absolute;bottom:0;left:0;right:0;
177
- background:linear-gradient(0deg,rgba(0,0,0,0.85) 0%,rgba(0,0,0,0.6) 60%,transparent 100%);
178
- padding:24px 16px 16px;
147
+ background:linear-gradient(0deg,rgba(0,0,0,0.7) 0%,transparent 100%);
148
+ padding:24px 10px 8px;text-align:center;
149
+ `;
150
+ this.timerEl = document.createElement("div");
151
+ this.timerEl.style.cssText = `
152
+ color:rgba(255,255,255,0.5);font-size:11px;font-variant-numeric:tabular-nums;
153
+ `;
154
+ camBottom.appendChild(this.timerEl);
155
+ // Action cue — centre of camera column
156
+ this.actionCueEl = document.createElement("div");
157
+ this.actionCueEl.style.cssText = `
158
+ position:absolute;bottom:36px;left:50%;transform:translateX(-50%);
159
+ z-index:5;pointer-events:none;display:none;text-align:center;
160
+ background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);
161
+ border-radius:14px;padding:10px 18px;min-width:130px;
162
+ border:1.5px solid rgba(255,255,255,0.12);
163
+ `;
164
+ camCol.appendChild(this.videoEl);
165
+ camCol.appendChild(guideWrap);
166
+ camCol.appendChild(camTop);
167
+ camCol.appendChild(camBottom);
168
+ camCol.appendChild(this.actionCueEl);
169
+ // ── RIGHT: Steps column ───────────────────────────────────────────
170
+ const stepsCol = document.createElement("div");
171
+ stepsCol.style.cssText = `
172
+ flex:1;display:flex;flex-direction:column;
173
+ padding:16px 14px;gap:8px;
174
+ background:#111;border-left:1px solid rgba(255,255,255,0.06);
175
+ justify-content:space-between;
179
176
  `;
180
- // Progress bar
177
+ // Progress bar at top of steps column
181
178
  const progressWrap = document.createElement("div");
182
179
  progressWrap.style.cssText = `
183
- height:3px;background:rgba(255,255,255,0.15);border-radius:2px;
184
- margin-bottom:14px;overflow:hidden;
180
+ height:3px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;
181
+ margin-bottom:4px;
185
182
  `;
186
183
  this.progressBarEl = document.createElement("div");
187
184
  this.progressBarEl.style.cssText = `
@@ -189,85 +186,55 @@ class LivenessUI {
189
186
  border-radius:2px;transition:width 0.4s ease;
190
187
  `;
191
188
  progressWrap.appendChild(this.progressBarEl);
192
- bottomPanel.appendChild(progressWrap);
193
- // Steps — compact pill row in top-right corner, always visible
189
+ // Steps list
194
190
  this.stepsEl = document.createElement("div");
195
- this.stepsEl.style.cssText = `
196
- position:absolute;top:10px;right:10px;
197
- display:flex;flex-direction:row;gap:6px;
198
- flex-wrap:wrap;justify-content:flex-end;
199
- z-index:6;pointer-events:none;
200
- max-width:calc(100% - 20px);
201
- `;
191
+ this.stepsEl.style.cssText = "display:flex;flex-direction:column;gap:6px;flex:1;";
202
192
  for (let i = 0; i < challenge.actions.length; i++) {
203
193
  const action = challenge.actions[i];
204
194
  const step = document.createElement("div");
205
195
  step.dataset.action = action;
206
196
  step.style.cssText = `
207
- display:flex;align-items:center;gap:5px;
208
- padding:5px 10px;border-radius:20px;
209
- background:rgba(0,0,0,0.55);
210
- backdrop-filter:blur(4px);
211
- border:1.5px solid rgba(255,255,255,0.15);
197
+ display:flex;align-items:center;gap:8px;
198
+ padding:8px 10px;border-radius:10px;
199
+ background:rgba(255,255,255,0.04);
200
+ border:1px solid rgba(255,255,255,0.06);
212
201
  transition:all 0.3s ease;
213
- opacity:${i === 0 ? "1" : "0.5"};
202
+ opacity:${i === 0 ? "1" : "0.45"};
214
203
  `;
215
204
  const num = document.createElement("div");
216
205
  num.className = "sc-step-num";
217
206
  num.style.cssText = `
218
- width:20px;height:20px;border-radius:50%;
207
+ width:24px;height:24px;border-radius:50%;flex-shrink:0;
219
208
  display:flex;align-items:center;justify-content:center;
220
- font-size:11px;font-weight:700;color:#fff;
221
- background:rgba(255,255,255,0.12);
222
- border:1.5px solid rgba(255,255,255,0.25);
223
- transition:all 0.3s ease;flex-shrink:0;
209
+ font-size:12px;font-weight:700;color:#fff;
210
+ background:rgba(255,255,255,0.1);
211
+ border:1.5px solid rgba(255,255,255,0.2);
212
+ transition:all 0.3s ease;
224
213
  `;
225
214
  num.textContent = String(i + 1);
215
+ const label = document.createElement("div");
216
+ label.style.cssText = "color:#fff;font-size:12px;font-weight:500;flex:1;line-height:1.3;";
217
+ label.textContent = this.describeAction(action);
226
218
  const icon = document.createElement("div");
227
- icon.className = "sc-step-icon";
228
- icon.style.cssText = "font-size:14px;flex-shrink:0;";
219
+ icon.style.cssText = "font-size:16px;flex-shrink:0;";
229
220
  icon.textContent = ICONS[action] || "";
230
221
  step.appendChild(num);
222
+ step.appendChild(label);
231
223
  step.appendChild(icon);
232
224
  this.stepsEl.appendChild(step);
233
225
  }
234
- // Timer
235
- this.timerEl = document.createElement("div");
236
- this.timerEl.style.cssText = `
237
- text-align:center;color:rgba(255,255,255,0.5);
238
- font-size:11px;font-variant-numeric:tabular-nums;
239
- `;
240
- bottomPanel.appendChild(this.timerEl);
241
- // Result overlay (used for success / failure / timeout)
226
+ stepsCol.appendChild(progressWrap);
227
+ stepsCol.appendChild(this.stepsEl);
228
+ // Result overlay — full root
242
229
  this.overlayEl = document.createElement("div");
243
230
  this.overlayEl.style.cssText = `
244
231
  position:absolute;inset:0;display:none;align-items:center;
245
232
  justify-content:center;z-index:10;
246
- background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
247
- transition:opacity 0.3s ease;
248
- `;
249
- // Action cue — large centred animation shown when an action is active
250
- this.actionCueEl = document.createElement("div");
251
- this.actionCueEl.style.cssText = `
252
- position:absolute;
253
- top:50%;left:50%;transform:translate(-50%,-50%);
254
- z-index:5;pointer-events:none;
255
- display:none;
256
- text-align:center;
257
- background:rgba(0,0,0,0.55);
258
- backdrop-filter:blur(4px);
259
- border-radius:20px;
260
- padding:18px 28px;
261
- min-width:160px;
262
- border:1.5px solid rgba(255,255,255,0.15);
233
+ background:rgba(0,0,0,0.88);backdrop-filter:blur(8px);
263
234
  `;
264
- // Assemble
265
- this.root.appendChild(this.videoEl);
266
- this.root.appendChild(guideWrap);
267
- this.root.appendChild(topBar);
268
- this.root.appendChild(this.stepsEl); // pinned top-right corner
269
- this.root.appendChild(bottomPanel);
270
- this.root.appendChild(this.actionCueEl);
235
+ // ── Assemble ──────────────────────────────────────────────────────
236
+ this.root.appendChild(camCol);
237
+ this.root.appendChild(stepsCol);
271
238
  this.root.appendChild(this.overlayEl);
272
239
  container.appendChild(this.root);
273
240
  this.startTimer(challenge.time_limit_seconds);
@@ -278,7 +245,6 @@ class LivenessUI {
278
245
  return;
279
246
  let completed = 0;
280
247
  const activeState = states.find((s) => s.active && !s.detected) ?? null;
281
- const activeAction = activeState?.action ?? null;
282
248
  for (const state of states) {
283
249
  const step = this.stepsEl.querySelector(`[data-action="${state.action}"]`);
284
250
  if (!step)
@@ -287,46 +253,42 @@ class LivenessUI {
287
253
  if (state.detected) {
288
254
  completed++;
289
255
  step.style.opacity = "1";
290
- step.style.background = "rgba(34,197,94,0.15)";
256
+ step.style.background = "rgba(34,197,94,0.12)";
257
+ step.style.borderColor = "rgba(34,197,94,0.3)";
291
258
  if (num) {
292
259
  num.style.background = "#22c55e";
293
260
  num.style.borderColor = "#22c55e";
294
- num.textContent = "\u2713";
261
+ num.textContent = "✓";
295
262
  }
296
263
  }
297
264
  else if (state.active) {
298
265
  step.style.opacity = "1";
299
- step.style.background = "rgba(59,130,246,0.15)";
266
+ step.style.background = "rgba(59,130,246,0.12)";
267
+ step.style.borderColor = "rgba(59,130,246,0.3)";
300
268
  if (num) {
301
269
  num.style.borderColor = "#3b82f6";
302
270
  num.style.background = "rgba(59,130,246,0.3)";
303
271
  }
304
272
  }
305
273
  }
306
- // Update progress bar
307
274
  const pct = this.totalActions > 0 ? (completed / this.totalActions) * 100 : 0;
308
275
  this.progressBarEl.style.width = `${pct}%`;
309
- // Update confidence ring for the active action
310
276
  if (this.confidenceRingEl) {
311
- const circumference = 2 * Math.PI * Math.sqrt((85 * 85 + 115 * 115) / 2);
277
+ const circ = 2 * Math.PI * Math.sqrt((64 * 64 + 86 * 86) / 2);
312
278
  const conf = activeState ? activeState.confidence : 0;
313
- const offset = circumference * (1 - conf);
314
- this.confidenceRingEl.setAttribute("stroke-dashoffset", `${offset}`);
279
+ this.confidenceRingEl.setAttribute("stroke-dashoffset", `${circ * (1 - conf)}`);
315
280
  this.confidenceRingEl.setAttribute("stroke", conf > 0.6 ? "#22c55e" : "#3b82f6");
316
281
  }
317
- // Show / switch large animated action cue
318
- this.updateActionCue(activeAction);
282
+ this.updateActionCue(activeState?.action ?? null);
319
283
  }
320
284
  updateActionCue(action) {
321
285
  if (!this.actionCueEl)
322
286
  return;
323
- // No active action → hide cue
324
287
  if (!action) {
325
288
  this.actionCueEl.style.display = "none";
326
289
  this.currentCueAction = null;
327
290
  return;
328
291
  }
329
- // Same action already showing — no DOM thrash
330
292
  if (action === this.currentCueAction)
331
293
  return;
332
294
  this.currentCueAction = action;
@@ -335,27 +297,23 @@ class LivenessUI {
335
297
  this.actionCueEl.style.display = "none";
336
298
  return;
337
299
  }
338
- // Inject content and animate in
339
300
  this.actionCueEl.innerHTML = cueHtml;
340
301
  this.actionCueEl.style.display = "block";
341
302
  this.actionCueEl.style.animation = "none";
342
- // Force reflow then trigger animation
343
303
  void this.actionCueEl.offsetWidth;
344
304
  this.actionCueEl.style.animation = "sc-pop-in 0.3s ease forwards";
345
305
  }
346
306
  updateInstruction(text) {
347
- if (this.instructionEl) {
307
+ if (this.instructionEl)
348
308
  this.instructionEl.textContent = text;
349
- }
350
309
  }
351
310
  stopTimer() {
352
311
  if (this.timerInterval) {
353
312
  clearInterval(this.timerInterval);
354
313
  this.timerInterval = null;
355
314
  }
356
- if (this.timerEl) {
315
+ if (this.timerEl)
357
316
  this.timerEl.textContent = "";
358
- }
359
317
  }
360
318
  showResult(status, verificationId) {
361
319
  return new Promise((resolve) => {
@@ -366,40 +324,22 @@ class LivenessUI {
366
324
  if (this.actionCueEl)
367
325
  this.actionCueEl.style.display = "none";
368
326
  this.stopTimer();
369
- const isProcessing = status === "processing";
327
+ const ok = status === "processing";
370
328
  this.overlayEl.style.display = "flex";
371
329
  this.overlayEl.innerHTML = `
372
330
  <div style="text-align:center;color:#fff;animation:sc-pop-in 0.35s ease;padding:24px;">
373
- <div style="
374
- width:80px;height:80px;border-radius:50%;margin:0 auto 20px;
375
- display:flex;align-items:center;justify-content:center;
376
- font-size:36px;
377
- background:${isProcessing ? "rgba(34,197,94,0.2)" : "rgba(239,68,68,0.2)"};
378
- border:3px solid ${isProcessing ? "#22c55e" : "#ef4444"};
379
- ">${isProcessing ? "\u2713" : "\u2717"}</div>
380
- <div style="font-size:19px;font-weight:700;margin-bottom:8px;">
381
- ${isProcessing ? "Identity Check Submitted" : "Submission Failed"}
331
+ <div style="width:72px;height:72px;border-radius:50%;margin:0 auto 16px;display:flex;align-items:center;justify-content:center;font-size:32px;
332
+ background:${ok ? "rgba(34,197,94,0.2)" : "rgba(239,68,68,0.2)"};
333
+ border:3px solid ${ok ? "#22c55e" : "#ef4444"};">
334
+ ${ok ? "✓" : "✕"}
382
335
  </div>
383
- <div style="font-size:13px;opacity:0.65;line-height:1.6;margin-bottom:${verificationId ? "16px" : "0"};">
384
- ${isProcessing
385
- ? "Your identity is being verified.<br>You\'ll be notified of the outcome shortly."
386
- : "Something went wrong. Please try again."}
387
- </div>
388
- ${verificationId ? `
389
- <div style="
390
- margin-top:4px;padding:8px 14px;border-radius:8px;
391
- background:rgba(255,255,255,0.08);display:inline-block;
392
- font-size:11px;color:rgba(255,255,255,0.5);letter-spacing:0.3px;
393
- ">Ref: #${verificationId}</div>` : ""}
394
- </div>
395
- `;
336
+ <div style="font-size:17px;font-weight:700;margin-bottom:8px;">${ok ? "Identity Check Submitted" : "Submission Failed"}</div>
337
+ <div style="font-size:12px;opacity:0.6;line-height:1.6;">${ok ? "Your identity is being verified.<br>You'll be notified of the outcome shortly." : "Something went wrong. Please try again."}</div>
338
+ ${verificationId ? `<div style="margin-top:12px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,0.07);display:inline-block;font-size:11px;color:rgba(255,255,255,0.4);">Ref: #${verificationId}</div>` : ""}
339
+ </div>`;
396
340
  setTimeout(resolve, 2800);
397
341
  });
398
342
  }
399
- /**
400
- * Show timeout screen with an optional retry callback.
401
- * Resolves immediately so the orchestrator can move to next step.
402
- */
403
343
  showTimeout(onRetry) {
404
344
  return new Promise((resolve) => {
405
345
  if (!this.overlayEl) {
@@ -412,58 +352,26 @@ class LivenessUI {
412
352
  this.overlayEl.style.display = "flex";
413
353
  this.overlayEl.innerHTML = `
414
354
  <div style="text-align:center;color:#fff;animation:sc-pop-in 0.35s ease;padding:24px;">
415
- <div style="
416
- width:80px;height:80px;border-radius:50%;margin:0 auto 16px;
417
- display:flex;align-items:center;justify-content:center;
418
- font-size:36px;
419
- background:rgba(251,191,36,0.15);
420
- border:3px solid #fbbf24;
421
- ">⏱</div>
422
- <div style="font-size:18px;font-weight:700;margin-bottom:8px;">Time's Up</div>
423
- <div style="font-size:13px;opacity:0.65;margin-bottom:20px;">
424
- You ran out of time. Please try again.
425
- </div>
426
- <button id="sc-retry-btn" style="
427
- padding:12px 28px;border-radius:10px;
428
- background:#3b82f6;color:#fff;
429
- border:none;cursor:pointer;
430
- font-size:14px;font-weight:600;
431
- width:100%;max-width:200px;
432
- ">Try Again</button>
433
- </div>
434
- `;
435
- // Wire retry button
436
- const retryBtn = this.overlayEl.querySelector("#sc-retry-btn");
437
- if (retryBtn) {
438
- retryBtn.addEventListener("click", () => {
439
- onRetry();
440
- resolve();
441
- });
355
+ <div style="width:72px;height:72px;border-radius:50%;margin:0 auto 14px;display:flex;align-items:center;justify-content:center;font-size:32px;background:rgba(251,191,36,0.15);border:3px solid #fbbf24;">⏱</div>
356
+ <div style="font-size:17px;font-weight:700;margin-bottom:8px;">Time's Up</div>
357
+ <div style="font-size:12px;opacity:0.6;margin-bottom:18px;">You ran out of time. Please try again.</div>
358
+ <button id="sc-retry-btn" style="padding:11px 28px;border-radius:10px;background:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:14px;font-weight:600;width:100%;max-width:180px;">Try Again</button>
359
+ </div>`;
360
+ const btn = this.overlayEl.querySelector("#sc-retry-btn");
361
+ if (btn) {
362
+ btn.addEventListener("click", () => { onRetry(); resolve(); });
442
363
  }
443
364
  else {
444
- // fallback — auto-resolve after 5s
445
365
  setTimeout(resolve, 5000);
446
366
  }
447
367
  });
448
368
  }
449
- getVideoElement() {
450
- return this.videoEl;
451
- }
452
- /**
453
- * Mount a document scanning step before the liveness challenge.
454
- * Reuses the already-open camera stream — no second permission prompt.
455
- * Resolves with the captured document Blob.
456
- */
369
+ getVideoElement() { return this.videoEl; }
457
370
  mountDocumentCapture(container, stream) {
458
371
  return new Promise((resolve, reject) => {
459
372
  injectLivenessKeyframes();
460
373
  const docRoot = document.createElement("div");
461
- docRoot.style.cssText = `
462
- position:relative;width:100%;max-width:420px;margin:0 auto;
463
- background:#0a0a0a;border-radius:16px;overflow:hidden;
464
- font-family:system-ui,-apple-system,sans-serif;
465
- box-shadow:0 8px 32px rgba(0,0,0,0.4);
466
- `;
374
+ docRoot.style.cssText = `position:relative;width:100%;max-width:420px;margin:0 auto;background:#0a0a0a;border-radius:16px;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,0.4);`;
467
375
  this.docCaptureRoot = docRoot;
468
376
  const docVideo = document.createElement("video");
469
377
  docVideo.autoplay = true;
@@ -471,50 +379,23 @@ class LivenessUI {
471
379
  docVideo.muted = true;
472
380
  docVideo.style.cssText = "width:100%;display:block;aspect-ratio:4/3;object-fit:cover;";
473
381
  docVideo.srcObject = stream;
474
- // Rectangle guide with blue corner accents
475
382
  const guideOverlay = document.createElement("div");
476
383
  guideOverlay.style.cssText = "position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;";
477
- guideOverlay.innerHTML = `
478
- <div style="width:83%;height:53%;border:2px dashed rgba(255,255,255,0.55);border-radius:10px;box-shadow:0 0 0 2000px rgba(0,0,0,0.45);position:relative;">
479
- <div style="position:absolute;top:-2px;left:-2px;width:18px;height:18px;border-top:3px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:4px 0 0 0;"></div>
480
- <div style="position:absolute;top:-2px;right:-2px;width:18px;height:18px;border-top:3px solid #3b82f6;border-right:3px solid #3b82f6;border-radius:0 4px 0 0;"></div>
481
- <div style="position:absolute;bottom:-2px;left:-2px;width:18px;height:18px;border-bottom:3px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:0 0 0 4px;"></div>
482
- <div style="position:absolute;bottom:-2px;right:-2px;width:18px;height:18px;border-bottom:3px solid #3b82f6;border-right:3px solid #3b82f6;border-radius:0 0 4px 0;"></div>
483
- </div>
484
- `;
384
+ guideOverlay.innerHTML = `<div style="width:83%;height:53%;border:2px dashed rgba(255,255,255,0.55);border-radius:10px;box-shadow:0 0 0 2000px rgba(0,0,0,0.45);position:relative;"><div style="position:absolute;top:-2px;left:-2px;width:18px;height:18px;border-top:3px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:4px 0 0 0;"></div><div style="position:absolute;top:-2px;right:-2px;width:18px;height:18px;border-top:3px solid #3b82f6;border-right:3px solid #3b82f6;border-radius:0 4px 0 0;"></div><div style="position:absolute;bottom:-2px;left:-2px;width:18px;height:18px;border-bottom:3px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:0 0 0 4px;"></div><div style="position:absolute;bottom:-2px;right:-2px;width:18px;height:18px;border-bottom:3px solid #3b82f6;border-right:3px solid #3b82f6;border-radius:0 0 4px 0;"></div></div>`;
485
385
  const header = document.createElement("div");
486
386
  header.style.cssText = "position:absolute;top:0;left:0;right:0;background:linear-gradient(180deg,rgba(0,0,0,0.88) 0%,transparent 100%);padding:18px 16px 40px;text-align:center;";
487
- header.innerHTML = `
488
- <div style="color:#fff;font-size:16px;font-weight:700;margin-bottom:6px;">Scan Your ID Document</div>
489
- <div style="color:rgba(255,255,255,0.6);font-size:12px;line-height:1.6;">
490
- Fit your document inside the frame.<br>Make sure all text and corners are visible.
491
- </div>
492
- `;
387
+ header.innerHTML = `<div style="color:#fff;font-size:16px;font-weight:700;margin-bottom:6px;">Scan Your ID Document</div><div style="color:rgba(255,255,255,0.6);font-size:12px;line-height:1.6;">Fit your document inside the frame.<br>Make sure all text and corners are visible.</div>`;
493
388
  const bottomPanel = document.createElement("div");
494
- bottomPanel.style.cssText = "position:absolute;bottom:0;left:0;right:0;background:linear-gradient(0deg,rgba(0,0,0,0.92) 0%,rgba(0,0,0,0.6) 70%,transparent 100%);padding:16px 16px;";
495
- bottomPanel.innerHTML = `
496
- <div style="font-size:11px;color:rgba(255,255,255,0.4);text-align:center;margin-bottom:12px;">
497
- 💡 Good lighting &nbsp;·&nbsp; No glare &nbsp;·&nbsp; All 4 corners visible
498
- </div>
499
- <button id="sc-capture-doc-btn" style="width:100%;padding:12px;border-radius:12px;background:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:15px;font-weight:700;margin-bottom:8px;">
500
- Capture Document
501
- </button>
502
- <label style="width:100%;display:flex;align-items:center;justify-content:center;gap:6px;padding:9px;border-radius:12px;border:1.5px solid rgba(255,255,255,0.18);cursor:pointer;color:rgba(255,255,255,0.6);font-size:13px;font-weight:500;">
503
- <span>📁</span><span>Upload from gallery instead</span>
504
- <input id="sc-upload-doc-input" type="file" accept="image/*" style="display:none;" />
505
- </label>
506
- `;
389
+ bottomPanel.style.cssText = "position:absolute;bottom:0;left:0;right:0;background:linear-gradient(0deg,rgba(0,0,0,0.92) 0%,rgba(0,0,0,0.6) 70%,transparent 100%);padding:16px;";
390
+ bottomPanel.innerHTML = `<div style="font-size:11px;color:rgba(255,255,255,0.4);text-align:center;margin-bottom:12px;">💡 Good lighting · No glare · All 4 corners visible</div><button id="sc-capture-doc-btn" style="width:100%;padding:12px;border-radius:12px;background:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:15px;font-weight:700;margin-bottom:8px;">Capture Document</button><label style="width:100%;display:flex;align-items:center;justify-content:center;gap:6px;padding:9px;border-radius:12px;border:1.5px solid rgba(255,255,255,0.18);cursor:pointer;color:rgba(255,255,255,0.6);font-size:13px;font-weight:500;"><span>📁</span><span>Upload from gallery instead</span><input id="sc-upload-doc-input" type="file" accept="image/*" style="display:none;" /></label>`;
507
391
  docRoot.appendChild(docVideo);
508
392
  docRoot.appendChild(guideOverlay);
509
393
  docRoot.appendChild(header);
510
394
  docRoot.appendChild(bottomPanel);
511
395
  container.appendChild(docRoot);
512
396
  docVideo.play().catch(() => { });
513
- const cleanup = () => {
514
- if (docRoot.parentElement)
515
- docRoot.parentElement.removeChild(docRoot);
516
- this.docCaptureRoot = null;
517
- };
397
+ const cleanup = () => { if (docRoot.parentElement)
398
+ docRoot.parentElement.removeChild(docRoot); this.docCaptureRoot = null; };
518
399
  const captureBtn = bottomPanel.querySelector("#sc-capture-doc-btn");
519
400
  if (captureBtn) {
520
401
  captureBtn.addEventListener("click", () => {
@@ -528,15 +409,13 @@ class LivenessUI {
528
409
  return;
529
410
  }
530
411
  ctx.drawImage(docVideo, 0, 0);
531
- canvas.toBlob((blob) => {
532
- if (blob) {
533
- cleanup();
534
- resolve(blob);
535
- }
536
- else {
537
- reject(new Error("Failed to capture document image"));
538
- }
539
- }, "image/jpeg", 0.92);
412
+ canvas.toBlob((blob) => { if (blob) {
413
+ cleanup();
414
+ resolve(blob);
415
+ }
416
+ else {
417
+ reject(new Error("Failed to capture document image"));
418
+ } }, "image/jpeg", 0.92);
540
419
  }
541
420
  catch (err) {
542
421
  reject(err);
@@ -545,23 +424,20 @@ class LivenessUI {
545
424
  }
546
425
  const uploadInput = bottomPanel.querySelector("#sc-upload-doc-input");
547
426
  if (uploadInput) {
548
- uploadInput.addEventListener("change", () => {
549
- const file = uploadInput.files?.[0];
550
- if (file) {
551
- cleanup();
552
- resolve(file);
553
- }
554
- });
427
+ uploadInput.addEventListener("change", () => { const file = uploadInput.files?.[0]; if (file) {
428
+ cleanup();
429
+ resolve(file);
430
+ } });
555
431
  }
556
432
  });
557
433
  }
558
434
  unmount() {
559
435
  this.stopTimer();
560
- if (this.docCaptureRoot && this.docCaptureRoot.parentElement) {
436
+ if (this.docCaptureRoot?.parentElement) {
561
437
  this.docCaptureRoot.parentElement.removeChild(this.docCaptureRoot);
562
438
  this.docCaptureRoot = null;
563
439
  }
564
- if (this.root && this.root.parentElement) {
440
+ if (this.root?.parentElement) {
565
441
  this.root.parentElement.removeChild(this.root);
566
442
  }
567
443
  this.root = null;
@@ -581,26 +457,20 @@ class LivenessUI {
581
457
  return;
582
458
  const m = Math.floor(remaining / 60);
583
459
  const s = remaining % 60;
584
- const display = m > 0 ? `${m}:${String(s).padStart(2, "0")}` : `${s}s`;
585
- this.timerEl.textContent = `${display} remaining`;
586
- if (remaining <= 5) {
460
+ this.timerEl.textContent = `${m > 0 ? `${m}:${String(s).padStart(2, "0")}` : `${s}s`} remaining`;
461
+ if (remaining <= 5)
587
462
  this.timerEl.style.color = "#ef4444";
588
- }
589
463
  };
590
464
  update();
591
- this.timerInterval = setInterval(() => {
592
- remaining--;
593
- update();
594
- if (remaining <= 0 && this.timerInterval) {
595
- clearInterval(this.timerInterval);
596
- }
597
- }, 1000);
465
+ this.timerInterval = setInterval(() => { remaining--; update(); if (remaining <= 0 && this.timerInterval) {
466
+ clearInterval(this.timerInterval);
467
+ } }, 1000);
598
468
  }
599
469
  describeAction(action) {
600
470
  const labels = {
601
471
  BLINK: "Blink your eyes",
602
- TURN_LEFT: "Turn your head right", // camera-left = user's right (mirror)
603
- TURN_RIGHT: "Turn your head left", // camera-right = user's left (mirror)
472
+ TURN_LEFT: "Turn your head to the side",
473
+ TURN_RIGHT: "Turn your head to the side",
604
474
  TURN_HEAD: "Turn your head side to side",
605
475
  OPEN_MOUTH: "Open your mouth wide",
606
476
  };