smartcomply-web-sdk 1.0.68 → 1.0.70

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 (41) hide show
  1. package/dist/esm/flow/DocumentCapture.d.ts +20 -0
  2. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
  3. package/dist/esm/flow/DocumentCapture.js +173 -19
  4. package/dist/esm/flow/DocumentCapture.js.map +1 -1
  5. package/dist/esm/flow/SmartComplyFlow.d.ts +12 -0
  6. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  7. package/dist/esm/flow/SmartComplyFlow.js +165 -22
  8. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  9. package/dist/esm/flow/icons.d.ts +1 -1
  10. package/dist/esm/flow/icons.d.ts.map +1 -1
  11. package/dist/esm/flow/icons.js +1 -0
  12. package/dist/esm/flow/icons.js.map +1 -1
  13. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  14. package/dist/esm/modules/liveness/LivenessUI.js +13 -3
  15. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  16. package/dist/esm/utils/ImageQuality.d.ts +12 -1
  17. package/dist/esm/utils/ImageQuality.d.ts.map +1 -1
  18. package/dist/esm/utils/ImageQuality.js +12 -1
  19. package/dist/esm/utils/ImageQuality.js.map +1 -1
  20. package/dist/flow/DocumentCapture.d.ts +20 -0
  21. package/dist/flow/DocumentCapture.d.ts.map +1 -1
  22. package/dist/flow/DocumentCapture.js +173 -19
  23. package/dist/flow/DocumentCapture.js.map +1 -1
  24. package/dist/flow/SmartComplyFlow.d.ts +12 -0
  25. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  26. package/dist/flow/SmartComplyFlow.js +165 -22
  27. package/dist/flow/SmartComplyFlow.js.map +1 -1
  28. package/dist/flow/icons.d.ts +1 -1
  29. package/dist/flow/icons.d.ts.map +1 -1
  30. package/dist/flow/icons.js +1 -0
  31. package/dist/flow/icons.js.map +1 -1
  32. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  33. package/dist/modules/liveness/LivenessUI.js +13 -3
  34. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  35. package/dist/smartcomply.browser.js +393 -44
  36. package/dist/smartcomply.browser.js.map +4 -4
  37. package/dist/utils/ImageQuality.d.ts +12 -1
  38. package/dist/utils/ImageQuality.d.ts.map +1 -1
  39. package/dist/utils/ImageQuality.js +12 -1
  40. package/dist/utils/ImageQuality.js.map +1 -1
  41. package/package.json +1 -1
@@ -4709,6 +4709,7 @@ var SmartComplySDK = (() => {
4709
4709
  this.videoEl.autoplay = true;
4710
4710
  this.videoEl.playsInline = true;
4711
4711
  this.videoEl.muted = true;
4712
+ this.videoEl.setAttribute("aria-label", "Live camera preview for face verification");
4712
4713
  this.videoEl.style.cssText = `
4713
4714
  width:100%;height:100%;display:block;
4714
4715
  filter:brightness(1.03) contrast(1.03);
@@ -4730,6 +4731,7 @@ var SmartComplySDK = (() => {
4730
4731
  this.guideWrapEl = guideWrap;
4731
4732
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
4732
4733
  svg.setAttribute("viewBox", "0 0 140 180");
4734
+ svg.setAttribute("aria-hidden", "true");
4733
4735
  svg.style.cssText = "width:100%;height:100%;overflow:visible;";
4734
4736
  const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
4735
4737
  const clip = document.createElementNS("http://www.w3.org/2000/svg", "clipPath");
@@ -4769,12 +4771,12 @@ var SmartComplySDK = (() => {
4769
4771
  confOval.setAttribute("ry", "86");
4770
4772
  confOval.setAttribute("fill", "none");
4771
4773
  confOval.setAttribute("stroke", this.colors.primary);
4772
- confOval.setAttribute("stroke-width", "2.5");
4773
- confOval.setAttribute("stroke-linecap", "round");
4774
+ confOval.setAttribute("stroke-width", "3");
4775
+ confOval.setAttribute("stroke-linecap", "butt");
4774
4776
  const circ = 2 * Math.PI * Math.sqrt((64 * 64 + 86 * 86) / 2);
4775
4777
  confOval.setAttribute("stroke-dasharray", `${circ}`);
4776
4778
  confOval.setAttribute("stroke-dashoffset", `${circ}`);
4777
- confOval.style.cssText = "transition:stroke-dashoffset 0.2s linear,stroke 0.3s ease;transform:rotate(-90deg);transform-origin:70px 90px;";
4779
+ confOval.style.cssText = "transition:stroke-dashoffset 0.2s linear,stroke 0.3s ease;transform:rotate(-90deg);transform-origin:70px 90px;filter:drop-shadow(0 0 3px rgba(0,0,0,0.25));";
4778
4780
  this.confidenceRingEl = confOval;
4779
4781
  const bracketGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
4780
4782
  bracketGroup.setAttribute("stroke", this.colors.primary);
@@ -4822,6 +4824,8 @@ var SmartComplySDK = (() => {
4822
4824
  padding:14px 16px 32px;
4823
4825
  `;
4824
4826
  this.instructionEl = document.createElement("div");
4827
+ this.instructionEl.setAttribute("aria-live", "polite");
4828
+ this.instructionEl.setAttribute("role", "status");
4825
4829
  this.instructionEl.style.cssText = `
4826
4830
  color:#fff;font-size:14px;font-weight:600;text-align:center;line-height:1.4;
4827
4831
  `;
@@ -5857,6 +5861,126 @@ var SmartComplySDK = (() => {
5857
5861
  return Math.min(maxWidth / srcWidth, maxHeight / srcHeight, 1);
5858
5862
  }
5859
5863
 
5864
+ // src/utils/ImageQuality.ts
5865
+ function calculateSharpness(imageData) {
5866
+ const { width, height, data } = imageData;
5867
+ const gray = new Uint8Array(width * height);
5868
+ for (let i2 = 0; i2 < width * height; i2++) {
5869
+ const idx = i2 * 4;
5870
+ gray[i2] = Math.round(0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]);
5871
+ }
5872
+ const laplacian = new Float32Array(width * height);
5873
+ let sum = 0;
5874
+ let sumSq = 0;
5875
+ let count = 0;
5876
+ for (let y2 = 1; y2 < height - 1; y2++) {
5877
+ for (let x2 = 1; x2 < width - 1; x2++) {
5878
+ const idx = y2 * width + x2;
5879
+ const val = -1 * gray[idx - width] + -1 * gray[idx - 1] + 4 * gray[idx] + -1 * gray[idx + 1] + -1 * gray[idx + width];
5880
+ laplacian[idx] = val;
5881
+ sum += val;
5882
+ sumSq += val * val;
5883
+ count++;
5884
+ }
5885
+ }
5886
+ const mean = sum / count;
5887
+ const variance = sumSq / count - mean * mean;
5888
+ return Math.abs(variance);
5889
+ }
5890
+ function detectGlare(imageData, threshold = 250) {
5891
+ const { data } = imageData;
5892
+ let overexposed = 0;
5893
+ for (let i2 = 0; i2 < data.length; i2 += 4) {
5894
+ const brightness = (data[i2] + data[i2 + 1] + data[i2 + 2]) / 3;
5895
+ if (brightness > threshold) {
5896
+ overexposed++;
5897
+ }
5898
+ }
5899
+ return overexposed / (data.length / 4);
5900
+ }
5901
+ function calculateContrast(imageData) {
5902
+ const { data } = imageData;
5903
+ let sum = 0;
5904
+ let sumSq = 0;
5905
+ const pixelCount = data.length / 4;
5906
+ for (let i2 = 0; i2 < data.length; i2 += 4) {
5907
+ const gray = 0.299 * data[i2] + 0.587 * data[i2 + 1] + 0.114 * data[i2 + 2];
5908
+ sum += gray;
5909
+ sumSq += gray * gray;
5910
+ }
5911
+ const mean = sum / pixelCount;
5912
+ const variance = sumSq / pixelCount - mean * mean;
5913
+ return Math.sqrt(variance);
5914
+ }
5915
+ function calculateBrightness(imageData) {
5916
+ const { data } = imageData;
5917
+ let sum = 0;
5918
+ for (let i2 = 0; i2 < data.length; i2 += 4) {
5919
+ sum += (data[i2] + data[i2 + 1] + data[i2 + 2]) / 3;
5920
+ }
5921
+ return sum / (data.length / 4);
5922
+ }
5923
+ function assessQuality(imageData) {
5924
+ const sharpness = calculateSharpness(imageData);
5925
+ const glare = detectGlare(imageData);
5926
+ const contrast = calculateContrast(imageData);
5927
+ const brightness = calculateBrightness(imageData);
5928
+ const isBlurry = sharpness < 80;
5929
+ const hasGlare = glare > 0.15;
5930
+ const isTooDark = brightness < 50;
5931
+ const isTooBright = brightness > 200;
5932
+ const lowContrast = contrast < 30;
5933
+ let score = 100;
5934
+ if (isBlurry) score -= 40;
5935
+ if (hasGlare) score -= 25;
5936
+ if (isTooDark) score -= 20;
5937
+ if (isTooBright) score -= 20;
5938
+ if (lowContrast) score -= 15;
5939
+ const metrics = {
5940
+ sharpness: Math.round(sharpness * 10) / 10,
5941
+ contrast: Math.round(contrast * 10) / 10,
5942
+ brightness: Math.round(brightness * 10) / 10,
5943
+ isBlurry,
5944
+ hasGlare,
5945
+ overallScore: Math.max(0, score)
5946
+ };
5947
+ const issues = [];
5948
+ const recommendations = [];
5949
+ if (isBlurry) {
5950
+ issues.push("Too blurry");
5951
+ recommendations.push("Hold camera steady");
5952
+ recommendations.push("Ensure good lighting");
5953
+ }
5954
+ if (hasGlare) {
5955
+ issues.push("Too much glare");
5956
+ recommendations.push("Avoid direct light on document");
5957
+ recommendations.push("Tilt document slightly");
5958
+ }
5959
+ if (isTooDark) {
5960
+ issues.push("Too dark");
5961
+ recommendations.push("Move to brighter area");
5962
+ recommendations.push("Turn on more lights");
5963
+ }
5964
+ if (isTooBright) {
5965
+ issues.push("Too bright");
5966
+ recommendations.push("Reduce lighting");
5967
+ recommendations.push("Move away from direct sun");
5968
+ }
5969
+ if (lowContrast) {
5970
+ issues.push("Low contrast");
5971
+ recommendations.push("Ensure even lighting");
5972
+ }
5973
+ const isValid = score >= 60;
5974
+ const message = issues.length > 0 ? issues.join(", ") : "Good quality";
5975
+ return {
5976
+ isValid,
5977
+ message,
5978
+ recommendations: recommendations.slice(0, 3),
5979
+ // Max 3 recommendations
5980
+ quality: metrics
5981
+ };
5982
+ }
5983
+
5860
5984
  // src/flow/icons.ts
5861
5985
  var PATHS = {
5862
5986
  shield: '<path d="M12 2l8 3v6c0 5-3.5 8.5-8 11-4.5-2.5-8-6-8-11V5l8-3z"/>',
@@ -5875,7 +5999,8 @@ var SmartComplySDK = (() => {
5875
5999
  passport: '<rect x="5" y="2" width="14" height="20" rx="2"/><circle cx="12" cy="9" r="2.5"/><path d="M9 15h6"/>',
5876
6000
  car: '<path d="M4 16V11l2-5h12l2 5v5"/><path d="M4 16h16M7 16v2M17 16v2"/><circle cx="7.5" cy="14" r="1"/><circle cx="16.5" cy="14" r="1"/>',
5877
6001
  ballot: '<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M9 8V5a3 3 0 016 0v3"/><path d="M12 12v4"/>',
5878
- refresh: '<path d="M4 4v6h6"/><path d="M20 20v-6h-6"/><path d="M20 10a8 8 0 00-14.6-4.6M4 14a8 8 0 0014.6 4.6"/>'
6002
+ refresh: '<path d="M4 4v6h6"/><path d="M20 20v-6h-6"/><path d="M20 10a8 8 0 00-14.6-4.6M4 14a8 8 0 0014.6 4.6"/>',
6003
+ rotate: '<rect x="7" y="7" width="12" height="12" rx="2"/><path d="M7 4h5a4 4 0 014 4"/><path d="M13 1.5L16 4l-3 2.5"/>'
5879
6004
  };
5880
6005
  function icon(name, size = 24) {
5881
6006
  const d2 = PATHS[name];
@@ -6126,7 +6251,7 @@ var SmartComplySDK = (() => {
6126
6251
  display:flex;align-items:center;gap:8px;
6127
6252
  font-size:12px;color:${theme.textSecondary};line-height:1.5;
6128
6253
  `;
6129
- notice.innerHTML = backOptional ? `<span style="font-size:16px;flex-shrink:0;">ℹ️</span><span>After the <strong style="color:${theme.text}">front</strong>, you can optionally add the <strong style="color:${theme.text}">back</strong> if your card has one.</span>` : `<span style="font-size:16px;flex-shrink:0;">ℹ️</span><span>You'll capture both the <strong style="color:${theme.text}">front</strong> and <strong style="color:${theme.text}">back</strong> of this document.</span>`;
6254
+ notice.innerHTML = backOptional ? `<span style="font-size:16px;flex-shrink:0;">ℹ️</span><span>After the <strong style="color:${theme.text}">front</strong>, you can optionally add the <strong style="color:${theme.text}">back</strong> if your document has one.</span>` : `<span style="font-size:16px;flex-shrink:0;">ℹ️</span><span>You'll capture both the <strong style="color:${theme.text}">front</strong> and <strong style="color:${theme.text}">back</strong> of this document.</span>`;
6130
6255
  this.root.appendChild(notice);
6131
6256
  }
6132
6257
  if (requiresBack && !isCapturingFront && backOptional) {
@@ -6137,7 +6262,7 @@ var SmartComplySDK = (() => {
6137
6262
  border:1.5px dashed ${theme.border};border-radius:10px;cursor:pointer;
6138
6263
  font-size:12.5px;font-weight:600;font-family:inherit;
6139
6264
  `;
6140
- skipBtn.textContent = "Skip — this card has no back side";
6265
+ skipBtn.textContent = "Skip — my document has no back side";
6141
6266
  skipBtn.addEventListener("click", () => {
6142
6267
  this.handleCaptureComplete(null, container, theme, documentType, false, true);
6143
6268
  });
@@ -6384,14 +6509,15 @@ var SmartComplySDK = (() => {
6384
6509
  stepBadge.textContent = "STEP 1 OF 2 · CHECK THE PHOTO";
6385
6510
  this.root.appendChild(stepBadge);
6386
6511
  const img = document.createElement("img");
6387
- const imageUrl = URL.createObjectURL(imageData);
6388
- img.src = imageUrl;
6512
+ let currentUrl = URL.createObjectURL(imageData);
6513
+ img.src = currentUrl;
6389
6514
  img.style.cssText = `
6390
6515
  width:100%;max-height:300px;object-fit:contain;border-radius:${RADIUS.md}px;
6391
6516
  border:2px solid ${theme.border};
6392
6517
  `;
6393
6518
  this.root.appendChild(img);
6394
- img._blobUrl = imageUrl;
6519
+ let currentBlob = imageData;
6520
+ let isRotating = false;
6395
6521
  const label = document.createElement("div");
6396
6522
  label.style.cssText = `color:${theme.text};font-size:${TYPE.base}px;font-weight:700;text-align:center;`;
6397
6523
  label.textContent = "Does the photo look clear?";
@@ -6400,31 +6526,157 @@ var SmartComplySDK = (() => {
6400
6526
  sublabel.style.cssText = `color:${theme.textMuted};font-size:${TYPE.xs}px;text-align:center;line-height:1.6;`;
6401
6527
  sublabel.textContent = "All text must be readable and all four corners must be visible.";
6402
6528
  this.root.appendChild(sublabel);
6529
+ const rotateBtn = document.createElement("button");
6530
+ rotateBtn.type = "button";
6531
+ rotateBtn.setAttribute("aria-label", "Rotate photo 90 degrees");
6532
+ rotateBtn.style.cssText = `
6533
+ display:flex;align-items:center;justify-content:center;gap:6px;
6534
+ margin:0 auto;padding:8px 16px;background:transparent;
6535
+ color:${theme.textSecondary};border:1.5px solid ${theme.border};
6536
+ border-radius:${RADIUS.pill}px;cursor:pointer;
6537
+ font-size:12.5px;font-weight:600;font-family:inherit;
6538
+ `;
6539
+ rotateBtn.innerHTML = `<span style="display:inline-flex;">${icon("rotate", 15)}</span><span>Rotate</span>`;
6540
+ rotateBtn.addEventListener("click", async () => {
6541
+ if (isRotating) return;
6542
+ isRotating = true;
6543
+ rotateBtn.disabled = true;
6544
+ try {
6545
+ const rotated = await this._rotateImage(currentBlob, 90);
6546
+ currentBlob = rotated;
6547
+ URL.revokeObjectURL(currentUrl);
6548
+ currentUrl = URL.createObjectURL(rotated);
6549
+ img.src = currentUrl;
6550
+ this._checkPhotoQuality(currentBlob, qualityNotice, theme);
6551
+ } catch {
6552
+ } finally {
6553
+ isRotating = false;
6554
+ rotateBtn.disabled = false;
6555
+ }
6556
+ });
6557
+ this.root.appendChild(rotateBtn);
6558
+ const qualityNotice = document.createElement("div");
6559
+ qualityNotice.style.cssText = `
6560
+ display:none;width:100%;padding:10px 14px;border-radius:${RADIUS.md}px;
6561
+ background:${theme.warning}15;border:1px solid ${theme.warning}35;
6562
+ color:${theme.text};font-size:12px;text-align:left;line-height:1.5;
6563
+ `;
6564
+ qualityNotice.setAttribute("role", "status");
6565
+ this.root.appendChild(qualityNotice);
6566
+ this._checkPhotoQuality(currentBlob, qualityNotice, theme);
6403
6567
  const btnRow = document.createElement("div");
6404
6568
  btnRow.style.cssText = "display:flex;gap:12px;width:100%;";
6405
6569
  const retakeBtn = this.createButton("Retake", theme, false);
6406
6570
  retakeBtn.style.flex = "1";
6407
6571
  retakeBtn.addEventListener("click", () => {
6408
- if (img._blobUrl) {
6409
- URL.revokeObjectURL(img._blobUrl);
6410
- }
6572
+ URL.revokeObjectURL(currentUrl);
6411
6573
  this.root.innerHTML = "";
6412
6574
  this.renderChoiceScreen(container, theme, documentType);
6413
6575
  });
6414
6576
  const useBtn = this.createButton("Use Photo ✓", theme, true);
6415
6577
  useBtn.style.flex = "1";
6416
6578
  useBtn.addEventListener("click", () => {
6417
- if (img._blobUrl) {
6418
- URL.revokeObjectURL(img._blobUrl);
6419
- }
6579
+ URL.revokeObjectURL(currentUrl);
6420
6580
  useBtn.textContent = "Confirming photo…";
6421
6581
  useBtn.disabled = true;
6422
- this.ensureUnderUploadLimit(imageData).then((c2) => this.handleCaptureComplete(c2, container, theme, documentType, viaCamera));
6582
+ rotateBtn.disabled = true;
6583
+ this.ensureUnderUploadLimit(currentBlob).then((c2) => this.handleCaptureComplete(c2, container, theme, documentType, viaCamera));
6423
6584
  });
6424
6585
  btnRow.appendChild(retakeBtn);
6425
6586
  btnRow.appendChild(useBtn);
6426
6587
  this.root.appendChild(btnRow);
6427
6588
  }
6589
+ /**
6590
+ * Runs the shared client-side quality heuristic (blur/glare/contrast/
6591
+ * brightness — see utils/ImageQuality.ts) against the just-captured photo
6592
+ * and, if it scores poorly, shows a plain-language nudge on the preview
6593
+ * screen. Fails silently on decode error — this is an advisory extra, not
6594
+ * a required step, so a browser that can't run it must not block capture.
6595
+ */
6596
+ async _checkPhotoQuality(imageData, noticeEl, theme) {
6597
+ try {
6598
+ const canvas = await this._blobToCanvas(imageData, 640);
6599
+ const ctx = canvas.getContext("2d");
6600
+ if (!ctx) return;
6601
+ const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
6602
+ const result = assessQuality(pixels);
6603
+ if (noticeEl.isConnected && !result.isValid) {
6604
+ noticeEl.style.display = "block";
6605
+ noticeEl.textContent = "This photo might be hard to read — " + (result.recommendations[0] || "try retaking it in good lighting") + ".";
6606
+ }
6607
+ } catch {
6608
+ }
6609
+ }
6610
+ /** Decodes to a canvas, optionally downscaled to maxDim on the long edge (never upscales). */
6611
+ _blobToCanvas(source, maxDim) {
6612
+ return new Promise((resolve, reject) => {
6613
+ const url = URL.createObjectURL(source);
6614
+ const imgEl = document.createElement("img");
6615
+ imgEl.onload = () => {
6616
+ URL.revokeObjectURL(url);
6617
+ const { naturalWidth: w2, naturalHeight: h2 } = imgEl;
6618
+ const ratio = maxDim ? computeScaleRatio(w2, h2, maxDim, maxDim) : 1;
6619
+ const canvas = document.createElement("canvas");
6620
+ canvas.width = Math.round(w2 * ratio);
6621
+ canvas.height = Math.round(h2 * ratio);
6622
+ const ctx = canvas.getContext("2d");
6623
+ if (!ctx) {
6624
+ reject(new Error("Canvas unavailable"));
6625
+ return;
6626
+ }
6627
+ ctx.drawImage(imgEl, 0, 0, canvas.width, canvas.height);
6628
+ resolve(canvas);
6629
+ };
6630
+ imgEl.onerror = () => {
6631
+ URL.revokeObjectURL(url);
6632
+ reject(new Error("Image load failed"));
6633
+ };
6634
+ imgEl.src = url;
6635
+ });
6636
+ }
6637
+ /**
6638
+ * Rotate an image blob by a multiple of 90° and re-encode it — used by the
6639
+ * preview screen's rotate control for a document photographed or uploaded
6640
+ * sideways/upside-down. EXIF auto-rotation (see compressImage) only fixes
6641
+ * phone photos that embed correct orientation metadata; it can't help a
6642
+ * document whose actual pixel content is sideways (e.g. the phone itself
6643
+ * was held rotated relative to the document, or a pre-cropped/scanned
6644
+ * upload with no EXIF at all) — that needs the user to say so directly.
6645
+ */
6646
+ _rotateImage(source, degrees) {
6647
+ return new Promise((resolve, reject) => {
6648
+ const url = URL.createObjectURL(source);
6649
+ const imgEl = document.createElement("img");
6650
+ imgEl.onload = () => {
6651
+ URL.revokeObjectURL(url);
6652
+ const { naturalWidth: w2, naturalHeight: h2 } = imgEl;
6653
+ const swapped = degrees === 90 || degrees === 270;
6654
+ const canvas = document.createElement("canvas");
6655
+ canvas.width = swapped ? h2 : w2;
6656
+ canvas.height = swapped ? w2 : h2;
6657
+ const ctx = canvas.getContext("2d");
6658
+ if (!ctx) {
6659
+ reject(new Error("Canvas unavailable"));
6660
+ return;
6661
+ }
6662
+ ctx.translate(canvas.width / 2, canvas.height / 2);
6663
+ ctx.rotate(degrees * Math.PI / 180);
6664
+ ctx.drawImage(imgEl, -w2 / 2, -h2 / 2);
6665
+ canvas.toBlob(
6666
+ (blob) => {
6667
+ blob ? resolve(blob) : reject(new Error("Rotation failed"));
6668
+ },
6669
+ "image/jpeg",
6670
+ 0.95
6671
+ );
6672
+ };
6673
+ imgEl.onerror = () => {
6674
+ URL.revokeObjectURL(url);
6675
+ reject(new Error("Image load failed"));
6676
+ };
6677
+ imgEl.src = url;
6678
+ });
6679
+ }
6428
6680
  handleCaptureComplete(imageData, container, theme, documentType, viaCamera, skipped = false) {
6429
6681
  const requiresBack = this.shouldCaptureBackSide(documentType);
6430
6682
  const wasCapturingFront = this.currentSide === "front";
@@ -6501,6 +6753,8 @@ var SmartComplySDK = (() => {
6501
6753
  if (!this.root) return;
6502
6754
  this.root.innerHTML = "";
6503
6755
  this.root.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:40px 20px;min-height:300px;";
6756
+ this.root.setAttribute("aria-live", "polite");
6757
+ this.root.setAttribute("role", "status");
6504
6758
  this.root.appendChild(renderSpinner(theme, { size: 80, icon: icon("document", 28) }));
6505
6759
  const loadingText = document.createElement("div");
6506
6760
  loadingText.style.cssText = `color:${theme.text};font-size:${TYPE.heading}px;font-weight:600;text-align:center;`;
@@ -6510,15 +6764,19 @@ var SmartComplySDK = (() => {
6510
6764
  showError(message) {
6511
6765
  if (!this.root) return;
6512
6766
  const errorContainer = document.createElement("div");
6767
+ errorContainer.setAttribute("role", "alert");
6513
6768
  errorContainer.style.cssText = `
6514
6769
  display:flex;align-items:center;gap:12px;padding:16px;
6515
6770
  background:#ff444415;border:1px solid #ff444430;border-radius:${RADIUS.md}px;
6516
6771
  color:#ff4444;font-size:14px;margin-top:16px;animation:shake 0.5s ease-in-out;
6517
6772
  `;
6518
- errorContainer.innerHTML = `
6519
- <span style="font-size:18px;">⚠️</span>
6520
- <span>${message}</span>
6521
- `;
6773
+ const errIcon = document.createElement("span");
6774
+ errIcon.style.fontSize = "18px";
6775
+ errIcon.textContent = "⚠️";
6776
+ const errText = document.createElement("span");
6777
+ errText.textContent = message;
6778
+ errorContainer.appendChild(errIcon);
6779
+ errorContainer.appendChild(errText);
6522
6780
  if (!document.head.querySelector("style[data-shake-animation]")) {
6523
6781
  const style = document.createElement("style");
6524
6782
  style.setAttribute("data-shake-animation", "true");
@@ -6693,13 +6951,32 @@ var SmartComplySDK = (() => {
6693
6951
  if (t2.includes("passport")) return "passport";
6694
6952
  if (t2.includes("driver") || t2.includes("licen")) return "driver's license";
6695
6953
  if (t2.includes("voter") || t2.includes("pvc")) return "voter's card";
6696
- if (t2.includes("national") || t2.includes("nin") || t2.includes("national id")) return "national ID card";
6697
- if (t2.includes("bvn")) return "BVN card";
6954
+ if (t2.includes("nin")) return "NIN document";
6955
+ if (t2.includes("national")) return "national ID card";
6956
+ if (t2.includes("bvn")) return "BVN document";
6698
6957
  return "identity document";
6699
6958
  }
6700
6959
  };
6701
6960
 
6702
6961
  // src/flow/SmartComplyFlow.ts
6962
+ var _INFRASTRUCTURE_ERROR_PATTERNS = [
6963
+ "camera connection was lost",
6964
+ "network error",
6965
+ "failed after all retries",
6966
+ "camera access is not supported",
6967
+ "camera access failed",
6968
+ "recording failed unexpectedly"
6969
+ ];
6970
+ function _isInfrastructureError(err) {
6971
+ const msg = String(err?.message || "").toLowerCase();
6972
+ return _INFRASTRUCTURE_ERROR_PATTERNS.some((p2) => msg.includes(p2));
6973
+ }
6974
+ function _userFacingLivenessError(err) {
6975
+ if (_isInfrastructureError(err)) {
6976
+ return "We lost connection during your scan — this wasn't anything you did. Please check your camera and internet connection, then try again.";
6977
+ }
6978
+ return err?.message || "We couldn't verify your face. Please try again.";
6979
+ }
6703
6980
  var COUNTRY_LABELS = {
6704
6981
  nigeria: "🇳🇬 Nigeria",
6705
6982
  global: "🌍 Other Countries",
@@ -6722,7 +6999,7 @@ var SmartComplySDK = (() => {
6722
6999
  if (u2.includes("NIN") || u2.includes("NATIONAL IDENTITY NUMBER")) {
6723
7000
  return {
6724
7001
  icon: "🆔",
6725
- desc: isDocumentFlow ? "Capture or upload your NIN card (back optional)" : "Enter your 11-digit National Identification Number (NIN)",
7002
+ desc: isDocumentFlow ? "Capture or upload your NIN (slip or card) — add the back if your document has one" : "Enter your 11-digit National Identification Number (NIN)",
6726
7003
  requiresBack: isDocumentFlow ? "optional" : false
6727
7004
  };
6728
7005
  }
@@ -6907,9 +7184,14 @@ var SmartComplySDK = (() => {
6907
7184
  this.isDestroyed = false;
6908
7185
  this.headerBackBtn = null;
6909
7186
  this._keydownHandler = null;
7187
+ this._visibilityHandler = null;
7188
+ this._hiddenAtMs = null;
7189
+ this._popstateHandler = null;
7190
+ this._historyGuardActive = false;
6910
7191
  // Retry safety — max 3 confirmation rejections or liveness failures per session
6911
7192
  this._confirmRetries = 0;
6912
7193
  this._livenessFailures = 0;
7194
+ this._livenessAttemptId = 0;
6913
7195
  // IVS result stored for confirm_identity screen
6914
7196
  this._ivsName = "";
6915
7197
  this._ivsGender = "";
@@ -6968,6 +7250,21 @@ var SmartComplySDK = (() => {
6968
7250
  document.removeEventListener("keydown", this._keydownHandler);
6969
7251
  this._keydownHandler = null;
6970
7252
  }
7253
+ if (this._visibilityHandler) {
7254
+ document.removeEventListener("visibilitychange", this._visibilityHandler);
7255
+ this._visibilityHandler = null;
7256
+ }
7257
+ if (this._popstateHandler) {
7258
+ window.removeEventListener("popstate", this._popstateHandler);
7259
+ this._popstateHandler = null;
7260
+ }
7261
+ if (this._historyGuardActive) {
7262
+ this._historyGuardActive = false;
7263
+ try {
7264
+ if (history.state?.scFlowGuard) history.back();
7265
+ } catch {
7266
+ }
7267
+ }
6971
7268
  this.docCapture?.destroy();
6972
7269
  if (this._prewarmedStream) {
6973
7270
  this._prewarmedStream.getTracks().forEach((t2) => t2.stop());
@@ -7084,6 +7381,43 @@ var SmartComplySDK = (() => {
7084
7381
  }
7085
7382
  };
7086
7383
  document.addEventListener("keydown", this._keydownHandler);
7384
+ const REBACKGROUND_RESTART_MS = 3e3;
7385
+ this._visibilityHandler = () => {
7386
+ if (this.isDestroyed) return;
7387
+ if (document.hidden) {
7388
+ this._hiddenAtMs = Date.now();
7389
+ return;
7390
+ }
7391
+ const hiddenAt = this._hiddenAtMs;
7392
+ this._hiddenAtMs = null;
7393
+ if (hiddenAt === null) return;
7394
+ const awayMs = Date.now() - hiddenAt;
7395
+ if (awayMs >= REBACKGROUND_RESTART_MS && this.currentStep === "liveness") {
7396
+ this.showStep("liveness");
7397
+ }
7398
+ };
7399
+ document.addEventListener("visibilitychange", this._visibilityHandler);
7400
+ this._historyGuardActive = true;
7401
+ try {
7402
+ history.pushState({ scFlowGuard: true }, "");
7403
+ } catch {
7404
+ this._historyGuardActive = false;
7405
+ }
7406
+ this._popstateHandler = () => {
7407
+ if (this.isDestroyed || !this._historyGuardActive) return;
7408
+ const backAction = this._getBackAction(this.currentStep);
7409
+ if (backAction) {
7410
+ backAction();
7411
+ } else {
7412
+ this.close();
7413
+ return;
7414
+ }
7415
+ try {
7416
+ history.pushState({ scFlowGuard: true }, "");
7417
+ } catch {
7418
+ }
7419
+ };
7420
+ window.addEventListener("popstate", this._popstateHandler);
7087
7421
  }
7088
7422
  async initialize() {
7089
7423
  this.showStep("loading");
@@ -7324,10 +7658,10 @@ var SmartComplySDK = (() => {
7324
7658
  container.appendChild(btn);
7325
7659
  const trust = document.createElement("div");
7326
7660
  trust.style.cssText = `
7327
- display:flex;align-items:center;gap:6px;
7328
- color:${this.theme.textMuted};font-size:10.5px;
7661
+ display:flex;align-items:flex-start;gap:6px;text-align:left;
7662
+ color:${this.theme.textMuted};font-size:10.5px;line-height:1.5;
7329
7663
  `;
7330
- trust.textContent = "🔒 End-to-end encrypted";
7664
+ trust.innerHTML = `<span style="flex-shrink:0;">🔒</span><span>Your photo and camera scan are used only to verify it's really you, and are kept encrypted.</span>`;
7331
7665
  container.appendChild(trust);
7332
7666
  }
7333
7667
  // ── Country Select ──────────────────────────────────────────────
@@ -8270,6 +8604,7 @@ var SmartComplySDK = (() => {
8270
8604
  this._renderRetryLimitReached(container);
8271
8605
  return;
8272
8606
  }
8607
+ const attemptId = ++this._livenessAttemptId;
8273
8608
  const title = this.createStepTitle("Face verification");
8274
8609
  container.appendChild(title);
8275
8610
  const desc = document.createElement("div");
@@ -8311,9 +8646,10 @@ var SmartComplySDK = (() => {
8311
8646
  warning: this.theme.warning
8312
8647
  }
8313
8648
  },
8314
- ["BLINK"],
8649
+ [...DEFAULT_CHALLENGE_ACTIONS],
8315
8650
  this.livenessEntryId || void 0
8316
8651
  ).then((result) => {
8652
+ if (attemptId !== this._livenessAttemptId) return;
8317
8653
  if (result.status === "failed") {
8318
8654
  this._livenessFailures++;
8319
8655
  }
@@ -8322,8 +8658,11 @@ var SmartComplySDK = (() => {
8322
8658
  }
8323
8659
  this.showStep("done");
8324
8660
  }).catch((err) => {
8325
- this._livenessFailures++;
8326
- this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
8661
+ if (attemptId !== this._livenessAttemptId) return;
8662
+ if (!_isInfrastructureError(err)) {
8663
+ this._livenessFailures++;
8664
+ }
8665
+ this.showErrorStep(_userFacingLivenessError(err));
8327
8666
  });
8328
8667
  }
8329
8668
  // ── Error ───────────────────────────────────────────────────────
@@ -8605,6 +8944,30 @@ var SmartComplySDK = (() => {
8605
8944
  const allFields = Object.values(channels).flat().flatMap((ch) => ch.fields || []);
8606
8945
  return allFields.some((f2) => isUploadFieldType(f2.type));
8607
8946
  }
8947
+ /**
8948
+ * The "go back one step" target for a given step, if that step is
8949
+ * navigable — shared by the header back button and the browser
8950
+ * back-button (popstate) handler in mount() so both agree on the same
8951
+ * step order.
8952
+ */
8953
+ _getBackAction(step) {
8954
+ const countries = Object.keys(this.sdkConfig?.channels || {});
8955
+ const backTargets = {
8956
+ id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
8957
+ country: () => this.showStep("welcome"),
8958
+ id_input: () => this.showStep("id_type"),
8959
+ confirm_identity: () => this.showStep("id_input"),
8960
+ document_capture: () => {
8961
+ this.docCapture?.destroy();
8962
+ this.showStep("id_type");
8963
+ },
8964
+ document_confirm: () => {
8965
+ this.docCapture?.destroy();
8966
+ this.showStep("document_capture");
8967
+ }
8968
+ };
8969
+ return backTargets[step];
8970
+ }
8608
8971
  updateProgress() {
8609
8972
  const isDocFlow = this.isDocumentFlow();
8610
8973
  const countries = Object.keys(this.sdkConfig?.channels || {});
@@ -8642,21 +9005,7 @@ var SmartComplySDK = (() => {
8642
9005
  if (dots[1]) dots[1].style.background = stage2Active ? this.theme.primary : this.theme.border;
8643
9006
  }
8644
9007
  if (this.headerBackBtn) {
8645
- const backTargets = {
8646
- id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
8647
- country: () => this.showStep("welcome"),
8648
- id_input: () => this.showStep("id_type"),
8649
- confirm_identity: () => this.showStep("id_input"),
8650
- document_capture: () => {
8651
- this.docCapture?.destroy();
8652
- this.showStep("id_type");
8653
- },
8654
- document_confirm: () => {
8655
- this.docCapture?.destroy();
8656
- this.showStep("document_capture");
8657
- }
8658
- };
8659
- const backAction = backTargets[this.currentStep];
9008
+ const backAction = this._getBackAction(this.currentStep);
8660
9009
  if (backAction) {
8661
9010
  this.headerBackBtn.style.display = "flex";
8662
9011
  const newBtn = this.headerBackBtn.cloneNode(true);