@tn3w/openage 1.0.4 → 1.0.6

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.
package/README.md CHANGED
@@ -16,11 +16,11 @@ button binding.
16
16
 
17
17
  ## At a Glance
18
18
 
19
- | Browser-side | Server-backed | UI |
20
- | ----------------------- | -------------------------- | -------------------------- |
21
- | On-device face analysis | Optional WASM verification | Embedded widget + popup |
22
- | No raw camera upload | Signed sessions and tokens | Normal, compact, invisible |
23
- | Serverless soft gates | Hosted or custom backend | Auto, light, dark |
19
+ | Browser-side | Server-backed | UI |
20
+ | ----------------------- | -------------------------- | -------------------------------- |
21
+ | On-device face analysis | Optional WASM verification | Widget popup, inline embed, bind |
22
+ | No raw camera upload | Signed sessions and tokens | Normal, compact, invisible |
23
+ | Serverless soft gates | Hosted or custom backend | Auto, light, dark |
24
24
 
25
25
  ## Install
26
26
 
@@ -54,12 +54,27 @@ import OpenAge from '@tn3w/openage';
54
54
 
55
55
  OpenAge.render('#gate', {
56
56
  mode: 'serverless',
57
+ layout: 'inline',
57
58
  minAge: 18,
58
59
  callback: (token) => console.log(token),
59
60
  errorCallback: (error) => console.error(error),
60
61
  });
61
62
  ```
62
63
 
64
+ ### Inline Embed
65
+
66
+ ```js
67
+ OpenAge.render('#gate', {
68
+ mode: 'serverless',
69
+ layout: 'inline',
70
+ minAge: 18,
71
+ });
72
+ ```
73
+
74
+ `layout: 'inline'` removes the checkbox shell and renders the verification
75
+ panel directly in the container. The first verification step starts
76
+ immediately after loading.
77
+
63
78
  ### Bound Flow
64
79
 
65
80
  ```js
@@ -95,11 +110,16 @@ OpenAge.execute(widgetId);
95
110
  await OpenAge.challenge(params);
96
111
  ```
97
112
 
113
+ Runtime errors keep the popup open long enough to explain what happened.
114
+ If no camera is available, OpenAge tells the user to plug one in and closes
115
+ the popup automatically after 5 seconds.
116
+
98
117
  ## Main Params
99
118
 
100
119
  | Param | Values |
101
120
  | --------- | --------------------------------- |
102
121
  | `mode` | `serverless`, `sitekey`, `custom` |
122
+ | `layout` | `widget`, `inline` |
103
123
  | `theme` | `light`, `dark`, `auto` |
104
124
  | `size` | `normal`, `compact`, `invisible` |
105
125
  | `minAge` | number, default `18` |
@@ -118,7 +138,7 @@ python server.py
118
138
  ```
119
139
 
120
140
  The repository also includes `demo/`, a minimal GitHub Pages build that loads
121
- the jsDelivr bundle for `@tn3w/openage` in embedded `serverless` mode.
141
+ the jsDelivr bundle for `@tn3w/openage` in inline embedded `serverless` mode.
122
142
 
123
143
  ## Development
124
144
 
@@ -541,6 +541,20 @@ const STYLES = `
541
541
  flex-direction: column;
542
542
  }
543
543
 
544
+ .oa-inline-shell {
545
+ background: var(--oa-bg);
546
+ border: 1px solid var(--oa-border);
547
+ border-radius: var(--oa-radius);
548
+ box-shadow: 0 20px 60px rgba(0,0,0,0.18),
549
+ 0 0 0 1px rgba(0,0,0,0.06);
550
+ width: min(100%, 360px);
551
+ max-width: 100%;
552
+ overflow: hidden;
553
+ display: flex;
554
+ flex-direction: column;
555
+ margin: 0 auto;
556
+ }
557
+
544
558
  .oa-modal-overlay {
545
559
  position: fixed;
546
560
  inset: 0;
@@ -893,6 +907,52 @@ const STYLES = `
893
907
  .oa-result-fail { color: var(--oa-danger); }
894
908
  .oa-result-retry { color: var(--oa-warn); }
895
909
 
910
+ .oa-error-step {
911
+ display: flex;
912
+ flex-direction: column;
913
+ align-items: center;
914
+ gap: 0.7rem;
915
+ padding: 2rem 1.2rem;
916
+ background: var(--oa-surface);
917
+ border: 1px solid var(--oa-border);
918
+ border-radius: var(--oa-radius);
919
+ margin: 10px;
920
+ animation: oa-fade-in 0.4s ease;
921
+ text-align: center;
922
+ }
923
+
924
+ .oa-error-step-icon {
925
+ width: 3rem;
926
+ height: 3rem;
927
+ display: flex;
928
+ align-items: center;
929
+ justify-content: center;
930
+ border-radius: 999px;
931
+ background: rgba(239, 68, 68, 0.12);
932
+ color: var(--oa-danger);
933
+ font-size: 1.7rem;
934
+ line-height: 1;
935
+ }
936
+
937
+ .oa-error-step-title {
938
+ font-size: 1rem;
939
+ font-weight: 700;
940
+ color: var(--oa-text);
941
+ }
942
+
943
+ .oa-error-step-message {
944
+ font-size: 0.84rem;
945
+ line-height: 1.5;
946
+ color: var(--oa-text-muted);
947
+ max-width: 260px;
948
+ }
949
+
950
+ .oa-error-step-countdown {
951
+ font-size: 0.75rem;
952
+ font-weight: 600;
953
+ color: var(--oa-danger);
954
+ }
955
+
896
956
  .oa-hidden { display: none !important; }
897
957
 
898
958
  /* ── Animations ──────────────────────────────── */
@@ -1019,6 +1079,17 @@ function challengeTemplate() {
1019
1079
  `;
1020
1080
  }
1021
1081
 
1082
+ function errorStepTemplate(message) {
1083
+ return `
1084
+ <div class="oa-error-step">
1085
+ <div class="oa-error-step-icon">✕</div>
1086
+ <div class="oa-error-step-title">Verification stopped</div>
1087
+ <div class="oa-error-step-message">${message}</div>
1088
+ <div class="oa-error-step-countdown"></div>
1089
+ </div>
1090
+ `;
1091
+ }
1092
+
1022
1093
  function resultTemplate(outcome, message) {
1023
1094
  const icons = {
1024
1095
  fail: '✕',
@@ -1040,7 +1111,7 @@ function resultTemplate(outcome, message) {
1040
1111
 
1041
1112
  const VERSION = '1.0.0';
1042
1113
 
1043
- const MEDIAPIPE_CDN = 'https://cdn.jsdelivr.net/npm/' + '@mediapipe/tasks-vision@0.10.17';
1114
+ const MEDIAPIPE_CDN = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17';
1044
1115
 
1045
1116
  const MEDIAPIPE_WASM = `${MEDIAPIPE_CDN}/wasm`;
1046
1117
 
@@ -1051,13 +1122,13 @@ const MEDIAPIPE_MODEL =
1051
1122
  'face_landmarker/face_landmarker/float16/1/' +
1052
1123
  'face_landmarker.task';
1053
1124
 
1054
- const FACEAPI_CDN =
1055
- 'https://cdn.jsdelivr.net/npm/' + 'face-api.js@0.22.2/dist/face-api.min.js';
1125
+ const FACEAPI_CDN = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js';
1056
1126
 
1057
1127
  const FACEAPI_MODEL_CDN =
1058
- 'https://cdn.jsdelivr.net/gh/' + 'justadudewhohacks/face-api.js@master/weights';
1128
+ 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights';
1059
1129
 
1060
1130
  const MAX_RETRIES = 3;
1131
+ const ERROR_STEP_SECONDS = 5;
1061
1132
  const BURST_FRAMES = 5;
1062
1133
  const BURST_INTERVAL_MS = 200;
1063
1134
  const POSITION_CHECK_MS = 100;
@@ -1088,6 +1159,7 @@ class Widget {
1088
1159
  this.popup = null;
1089
1160
  this.shadow = null;
1090
1161
  this.elements = {};
1162
+ this.popupElements = null;
1091
1163
  this.onChallenge = null;
1092
1164
  this.onStartClick = null;
1093
1165
  this.popupFrame = 0;
@@ -1100,6 +1172,7 @@ class Widget {
1100
1172
  const host = document.createElement('div');
1101
1173
  host.id = this.id;
1102
1174
  this.shadow = host.attachShadow({ mode: 'open' });
1175
+ this.host = host;
1103
1176
 
1104
1177
  const style = document.createElement('style');
1105
1178
  style.textContent = STYLES;
@@ -1112,7 +1185,12 @@ class Widget {
1112
1185
  if (this.params.size === 'invisible') {
1113
1186
  host.style.display = 'none';
1114
1187
  this.container.appendChild(host);
1115
- this.host = host;
1188
+ return;
1189
+ }
1190
+
1191
+ if (this.isInlineLayout()) {
1192
+ this.renderInlineShell();
1193
+ this.container.appendChild(host);
1116
1194
  return;
1117
1195
  }
1118
1196
 
@@ -1148,7 +1226,32 @@ class Widget {
1148
1226
  this.elements.errorSlot = this.shadow.querySelector('.oa-error-slot');
1149
1227
 
1150
1228
  this.container.appendChild(host);
1151
- this.host = host;
1229
+ }
1230
+
1231
+ isInlineLayout() {
1232
+ return this.params.layout === 'inline';
1233
+ }
1234
+
1235
+ renderInlineShell() {
1236
+ const inlineShell = document.createElement('div');
1237
+ inlineShell.className = 'oa-inline-shell';
1238
+ inlineShell.innerHTML = this.buildPopupContent({ closeable: false });
1239
+ this.shadow.appendChild(inlineShell);
1240
+
1241
+ this.popup = {
1242
+ host: this.host,
1243
+ root: inlineShell,
1244
+ inline: true,
1245
+ };
1246
+
1247
+ this.bindPopupEvents(inlineShell);
1248
+ }
1249
+
1250
+ resetInlineShell() {
1251
+ if (!this.popup?.root) return;
1252
+
1253
+ this.popup.root.innerHTML = this.buildPopupContent({ closeable: false });
1254
+ this.bindPopupEvents(this.popup.root);
1152
1255
  }
1153
1256
 
1154
1257
  startChallenge() {
@@ -1181,6 +1284,14 @@ class Widget {
1181
1284
  }
1182
1285
 
1183
1286
  openPopup() {
1287
+ if (this.isInlineLayout()) {
1288
+ if (!this.popup) {
1289
+ this.renderInlineShell();
1290
+ }
1291
+
1292
+ return this.getVideo();
1293
+ }
1294
+
1184
1295
  if (this.popup) return this.getVideo();
1185
1296
 
1186
1297
  const anchor = this.getPopupAnchor();
@@ -1358,7 +1469,7 @@ class Widget {
1358
1469
  this.popup.root.style.pointerEvents = 'auto';
1359
1470
  }
1360
1471
 
1361
- buildPopupContent() {
1472
+ buildPopupContent({ closeable = true } = {}) {
1362
1473
  return `
1363
1474
  <div class="oa-header">
1364
1475
  <div class="oa-title">
@@ -1369,10 +1480,14 @@ class Widget {
1369
1480
  </a>
1370
1481
  <span class="oa-badge">on-device</span>
1371
1482
  </div>
1372
- <button class="oa-close-btn"
1373
- aria-label="Close">
1374
- ${CLOSE_SVG}
1375
- </button>
1483
+ ${
1484
+ closeable
1485
+ ? `<button class="oa-close-btn"
1486
+ aria-label="Close">
1487
+ ${CLOSE_SVG}
1488
+ </button>`
1489
+ : ''
1490
+ }
1376
1491
  </div>
1377
1492
  <div class="oa-body">
1378
1493
  ${heroTemplate('Initializing…')}
@@ -1385,7 +1500,7 @@ class Widget {
1385
1500
  `;
1386
1501
  }
1387
1502
 
1388
- bindPopupEvents(root, shadow) {
1503
+ bindPopupEvents(root) {
1389
1504
  const closeBtn = root.querySelector('.oa-close-btn');
1390
1505
  if (closeBtn) {
1391
1506
  closeBtn.addEventListener('click', () => {
@@ -1503,6 +1618,22 @@ class Widget {
1503
1618
  }
1504
1619
 
1505
1620
  showResult(outcome, message) {
1621
+ if (this.isInlineLayout()) {
1622
+ if (!this.popupElements?.body) return;
1623
+
1624
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
1625
+ this.hideActions();
1626
+
1627
+ if (outcome === 'pass') {
1628
+ this.setState('verified');
1629
+ return;
1630
+ }
1631
+
1632
+ this.setState(outcome === 'fail' ? 'failed' : 'retry');
1633
+ this.showActions('Try Again');
1634
+ return;
1635
+ }
1636
+
1506
1637
  if (outcome === 'pass') {
1507
1638
  this.closePopup();
1508
1639
  this.setState('verified');
@@ -1538,8 +1669,21 @@ class Widget {
1538
1669
  }
1539
1670
  }
1540
1671
 
1541
- showError() {
1542
- this.setState('retry');
1672
+ showError(message) {
1673
+ if (!this.popupElements?.body) return;
1674
+
1675
+ this.popupElements.body.innerHTML = errorStepTemplate(message);
1676
+ this.popupElements.errorCountdown = this.popupElements.body.querySelector(
1677
+ '.oa-error-step-countdown'
1678
+ );
1679
+ this.hideActions();
1680
+ }
1681
+
1682
+ setErrorCountdown(seconds) {
1683
+ if (!this.popupElements?.errorCountdown) return;
1684
+
1685
+ const unit = seconds === 1 ? 'second' : 'seconds';
1686
+ this.popupElements.errorCountdown.textContent = `Closing in ${seconds} ${unit}…`;
1543
1687
  }
1544
1688
 
1545
1689
  clearError() {
@@ -1550,6 +1694,22 @@ class Widget {
1550
1694
 
1551
1695
  closePopup() {
1552
1696
  if (!this.popup) return;
1697
+
1698
+ if (this.popup.inline) {
1699
+ this.popup.cleanup?.();
1700
+ if (this.popupFrame) {
1701
+ cancelAnimationFrame(this.popupFrame);
1702
+ this.popupFrame = 0;
1703
+ }
1704
+ this.resetInlineShell();
1705
+
1706
+ if (this.state === 'loading') {
1707
+ this.setState('idle');
1708
+ }
1709
+
1710
+ return;
1711
+ }
1712
+
1553
1713
  this.popup.cleanup?.();
1554
1714
  this.popup.themeCleanup?.();
1555
1715
  this.popup.host.remove();
@@ -2717,6 +2877,73 @@ function sleep(ms) {
2717
2877
  return new Promise((r) => setTimeout(r, ms));
2718
2878
  }
2719
2879
 
2880
+ function resolveChallengeErrorMessage(error) {
2881
+ const name = typeof error?.name === 'string' ? error.name : '';
2882
+ const message = typeof error?.message === 'string' ? error.message : String(error || '');
2883
+ const normalized = `${name} ${message}`.toLowerCase();
2884
+
2885
+ if (
2886
+ name === 'NotFoundError' ||
2887
+ /request is not allowed by the user agent or the platform in the current context/.test(
2888
+ normalized
2889
+ ) ||
2890
+ /requested device not found|device not found|no camera|could not start video source/.test(
2891
+ normalized
2892
+ )
2893
+ ) {
2894
+ return 'No camera available. Plug in a camera and try again.';
2895
+ }
2896
+
2897
+ if (
2898
+ name === 'NotAllowedError' ||
2899
+ name === 'PermissionDeniedError' ||
2900
+ /permission|camera access|access denied/.test(normalized)
2901
+ ) {
2902
+ return 'Camera access was blocked. Allow camera access and try again.';
2903
+ }
2904
+
2905
+ if (/positioning timeout/.test(normalized)) {
2906
+ return 'Face positioning timed out. Reopen the check and try again.';
2907
+ }
2908
+
2909
+ return 'Verification failed. Please try again.';
2910
+ }
2911
+
2912
+ function isInlineLayout(widget) {
2913
+ return widget?.params?.layout === 'inline';
2914
+ }
2915
+
2916
+ async function showErrorStep(widget, error) {
2917
+ const message = resolveChallengeErrorMessage(error);
2918
+
2919
+ if (isInlineLayout(widget)) {
2920
+ widget.showResult?.('retry', message);
2921
+ return;
2922
+ }
2923
+
2924
+ if (!widget.popup) {
2925
+ widget.openPopup?.();
2926
+ }
2927
+
2928
+ widget.showError?.(message);
2929
+
2930
+ for (let seconds = ERROR_STEP_SECONDS; seconds > 0; seconds--) {
2931
+ if (!widget.popup) break;
2932
+ widget.setErrorCountdown?.(seconds);
2933
+ await sleep(1000);
2934
+ }
2935
+
2936
+ widget.closePopup?.();
2937
+ widget.setState?.('retry');
2938
+ }
2939
+
2940
+ async function handleChallengeError(widget, emitter, error) {
2941
+ console.log('Error during challenge:', error);
2942
+ await showErrorStep(widget, error);
2943
+ emitter.emit('error', error, widget.id);
2944
+ widget.params.errorCallback?.(error);
2945
+ }
2946
+
2720
2947
  async function runChallenge(widget, emitter) {
2721
2948
  const mode = widget.params.mode || 'serverless';
2722
2949
 
@@ -2746,10 +2973,14 @@ async function runServerless(widget, emitter) {
2746
2973
  await Promise.all([loadVision(), initAgeEstimator()]);
2747
2974
 
2748
2975
  modelBuffer = await loadModel();
2749
- widget.showReady();
2750
2976
 
2751
- await waitForStart(widget);
2752
- await startCameraFlow(widget, modelBuffer);
2977
+ if (isInlineLayout(widget)) {
2978
+ await startCameraFlow(widget, modelBuffer);
2979
+ } else {
2980
+ widget.showReady();
2981
+ await waitForStart(widget);
2982
+ await startCameraFlow(widget, modelBuffer);
2983
+ }
2753
2984
 
2754
2985
  const transport = createTransport('serverless', params);
2755
2986
 
@@ -2797,10 +3028,7 @@ async function runServerless(widget, emitter) {
2797
3028
  emitResult(widget, emitter, result);
2798
3029
  } catch (error) {
2799
3030
  cleanupLocal();
2800
- console.log('Error during challenge:', error);
2801
- widget.showResult('fail', 'Verification failed');
2802
- emitter.emit('error', error, widget.id);
2803
- params.errorCallback?.(error);
3031
+ await handleChallengeError(widget, emitter, error);
2804
3032
  }
2805
3033
  }
2806
3034
 
@@ -2841,10 +3069,16 @@ async function runServer(widget, emitter) {
2841
3069
  captureFrame: () => (captureFrame() ? 'true' : 'null'),
2842
3070
  });
2843
3071
 
2844
- widget.showReady();
2845
- await waitForStart(widget);
3072
+ let video;
3073
+
3074
+ if (isInlineLayout(widget)) {
3075
+ video = widget.showCamera();
3076
+ } else {
3077
+ widget.showReady();
3078
+ await waitForStart(widget);
3079
+ video = widget.showCamera();
3080
+ }
2846
3081
 
2847
- const video = widget.showCamera();
2848
3082
  widget.setVideoStatus('Requesting camera…');
2849
3083
  await startCamera(video);
2850
3084
  exposeMirrorVideo(video);
@@ -2931,11 +3165,8 @@ async function runServer(widget, emitter) {
2931
3165
 
2932
3166
  cleanupVM(transport);
2933
3167
  } catch (error) {
2934
- console.log('Error during challenge:', error);
2935
3168
  cleanupVM();
2936
- widget.showResult('fail', 'Verification failed');
2937
- emitter.emit('error', error, widget.id);
2938
- params.errorCallback?.(error);
3169
+ await handleChallengeError(widget, emitter, error);
2939
3170
  }
2940
3171
  }
2941
3172
 
@@ -3150,8 +3381,19 @@ class EventEmitter {
3150
3381
  const emitter = new EventEmitter();
3151
3382
  const widgets = new Map();
3152
3383
 
3384
+ function normalizeLayout(layout) {
3385
+ if (layout === 'inline' || layout === 'embed' || layout === 'embedded') {
3386
+ return 'inline';
3387
+ }
3388
+
3389
+ return 'widget';
3390
+ }
3391
+
3153
3392
  function normalizeParams(params) {
3154
3393
  const globalConfig = typeof window !== 'undefined' ? window.openage || {} : {};
3394
+ const layout = normalizeLayout(
3395
+ params.layout ?? params.display ?? globalConfig.layout ?? globalConfig.display
3396
+ );
3155
3397
 
3156
3398
  return {
3157
3399
  mode: 'serverless',
@@ -3160,6 +3402,7 @@ function normalizeParams(params) {
3160
3402
  minAge: 18,
3161
3403
  ...globalConfig,
3162
3404
  ...params,
3405
+ layout,
3163
3406
  };
3164
3407
  }
3165
3408
 
@@ -3168,6 +3411,10 @@ function startWidget(widget) {
3168
3411
  emitter.emit('opened', widget.id);
3169
3412
  runChallenge(widget, emitter);
3170
3413
  };
3414
+
3415
+ if (widget.params.layout === 'inline') {
3416
+ widget.startChallenge();
3417
+ }
3171
3418
  }
3172
3419
 
3173
3420
  function render(container, params = {}) {
@@ -3316,6 +3563,7 @@ function autoRender() {
3316
3563
  size: element.dataset.size,
3317
3564
  action: element.dataset.action,
3318
3565
  mode: element.dataset.mode,
3566
+ layout: element.dataset.layout || element.dataset.display,
3319
3567
  server: element.dataset.server,
3320
3568
  };
3321
3569