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