@tn3w/openage 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3408 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.OpenAge = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ const FACE_SVG = `<svg viewBox="0 0 100 120"
8
+ fill="none" class="oa-face-svg">
9
+ <ellipse cx="50" cy="55" rx="35" ry="45"
10
+ stroke="currentColor" stroke-width="1.8"/>
11
+ <ellipse cx="36" cy="45" rx="5" ry="3.5"
12
+ fill="currentColor" class="oa-eye"/>
13
+ <ellipse cx="64" cy="45" rx="5" ry="3.5"
14
+ fill="currentColor" class="oa-eye"/>
15
+ <line x1="50" y1="52" x2="48" y2="62"
16
+ stroke="currentColor" stroke-width="1.5"
17
+ stroke-linecap="round"/>
18
+ <path d="M40 72 Q50 79 60 72"
19
+ stroke="currentColor" stroke-width="1.5"
20
+ stroke-linecap="round" fill="none"/>
21
+ </svg>`;
22
+
23
+ const FACE_ICON_SVG = `<svg viewBox="0 0 100 120"
24
+ fill="none" class="oa-face-icon-svg">
25
+ <ellipse cx="50" cy="55" rx="35" ry="45"
26
+ stroke="currentColor" stroke-width="2"/>
27
+ <ellipse cx="36" cy="45" rx="5" ry="3.5"
28
+ fill="currentColor" class="oa-eye"/>
29
+ <ellipse cx="64" cy="45" rx="5" ry="3.5"
30
+ fill="currentColor" class="oa-eye"/>
31
+ <line x1="50" y1="52" x2="48" y2="62"
32
+ stroke="currentColor" stroke-width="1.65"
33
+ stroke-linecap="round"/>
34
+ <path d="M40 72 Q50 79 60 72"
35
+ stroke="currentColor" stroke-width="1.65"
36
+ stroke-linecap="round" fill="none"/>
37
+ </svg>`;
38
+
39
+ const FACE_GUIDE_SVG = `<svg viewBox="0 0 100 120"
40
+ fill="none" class="oa-face-svg">
41
+ <ellipse cx="50" cy="55" rx="35" ry="45"
42
+ stroke="currentColor" stroke-width="1.8"
43
+ stroke-dasharray="6 4"/>
44
+ <ellipse cx="36" cy="45" rx="5" ry="3.5"
45
+ fill="currentColor" class="oa-eye"/>
46
+ <ellipse cx="64" cy="45" rx="5" ry="3.5"
47
+ fill="currentColor" class="oa-eye"/>
48
+ <line x1="50" y1="52" x2="48" y2="62"
49
+ stroke="currentColor" stroke-width="1.5"
50
+ stroke-linecap="round"/>
51
+ <path d="M40 72 Q50 79 60 72"
52
+ stroke="currentColor" stroke-width="1.5"
53
+ stroke-linecap="round" fill="none"/>
54
+ </svg>`;
55
+
56
+ const CHECK_SVG = `<svg viewBox="0 0 24 24"
57
+ fill="none" stroke="currentColor" stroke-width="2.5"
58
+ stroke-linecap="round" stroke-linejoin="round">
59
+ <polyline points="20 6 9 17 4 12"/>
60
+ </svg>`;
61
+
62
+ const CLOSE_SVG = `<svg viewBox="0 0 24 24"
63
+ fill="none" stroke="currentColor" stroke-width="2"
64
+ stroke-linecap="round">
65
+ <line x1="18" y1="6" x2="6" y2="18"/>
66
+ <line x1="6" y1="6" x2="18" y2="18"/>
67
+ </svg>`;
68
+
69
+ const RETRY_SVG = `<svg viewBox="0 0 16 16"
70
+ xmlns="http://www.w3.org/2000/svg"
71
+ fill="currentColor">
72
+ <path d="m14.955 7.986.116.01a1 1 0 0 1 .85 1.13 8 8 0 0 1-13.374 4.728l-.84.84c-.63.63-1.707.184-1.707-.707V10h3.987c.89 0 1.337 1.077.707 1.707l-.731.731a6 6 0 0 0 8.347-.264 6 6 0 0 0 1.63-3.33 1 1 0 0 1 1.131-.848zM11.514.813a8 8 0 0 1 1.942 1.336l.837-.837c.63-.63 1.707-.184 1.707.707V6h-3.981c-.89 0-1.337-1.077-.707-1.707l.728-.729a6 6 0 0 0-9.98 3.591 1 1 0 1 1-1.98-.281A8 8 0 0 1 11.514.813"/>
73
+ </svg>`;
74
+
75
+ const SPINNER_SVG = `<svg viewBox="0 0 24 24"
76
+ fill="none" stroke="currentColor" stroke-width="2.5">
77
+ <circle cx="12" cy="12" r="10" opacity="0.2"/>
78
+ <path d="M12 2 A10 10 0 0 1 22 12"
79
+ stroke-linecap="round">
80
+ <animateTransform attributeName="transform"
81
+ type="rotate" from="0 12 12" to="360 12 12"
82
+ dur="0.8s" repeatCount="indefinite"/>
83
+ </path>
84
+ </svg>`;
85
+
86
+ const SHIELD_SVG = `<svg viewBox="0 0 24 24"
87
+ fill="none" stroke="currentColor" stroke-width="1.5">
88
+ <path d="M12 2l8 4v6c0 5.5-3.8 10-8 12
89
+ C7.8 22 4 17.5 4 12V6l8-4z"/>
90
+ <path d="M9 12l2 2 4-4" stroke-width="2"
91
+ stroke-linecap="round" stroke-linejoin="round"/>
92
+ </svg>`;
93
+
94
+ function resolveTheme(theme) {
95
+ if (theme === 'light' || theme === 'dark') return theme;
96
+ return resolveAutoTheme();
97
+ }
98
+
99
+ function watchTheme(host, theme) {
100
+ if (theme === 'light' || theme === 'dark') return;
101
+ if (typeof window === 'undefined') return;
102
+
103
+ const update = () => {
104
+ host.setAttribute('data-theme', resolveAutoTheme());
105
+ };
106
+
107
+ const cleanups = [];
108
+
109
+ for (const query of ['(prefers-color-scheme: dark)', '(prefers-color-scheme: light)']) {
110
+ const cleanup = watchMediaQuery(query, update);
111
+ if (cleanup) cleanups.push(cleanup);
112
+ }
113
+
114
+ if (typeof MutationObserver === 'function') {
115
+ const observer = new MutationObserver(update);
116
+ const options = {
117
+ attributes: true,
118
+ attributeFilter: ['class', 'data-theme', 'style'],
119
+ };
120
+
121
+ if (document.documentElement) {
122
+ observer.observe(document.documentElement, options);
123
+ }
124
+
125
+ if (document.body) {
126
+ observer.observe(document.body, options);
127
+ }
128
+
129
+ cleanups.push(() => observer.disconnect());
130
+ }
131
+
132
+ return () => {
133
+ for (const cleanup of cleanups) {
134
+ cleanup();
135
+ }
136
+ };
137
+ }
138
+
139
+ function resolveAutoTheme() {
140
+ if (typeof window === 'undefined') return 'dark';
141
+
142
+ const documentTheme = resolveDocumentTheme();
143
+ if (documentTheme) return documentTheme;
144
+
145
+ const systemTheme = resolveSystemTheme();
146
+ if (systemTheme) return systemTheme;
147
+
148
+ return 'dark';
149
+ }
150
+
151
+ function resolveSystemTheme() {
152
+ if (typeof window.matchMedia !== 'function') {
153
+ return null;
154
+ }
155
+
156
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
157
+ return 'dark';
158
+ }
159
+
160
+ if (window.matchMedia('(prefers-color-scheme: light)').matches) {
161
+ return 'light';
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ function resolveDocumentTheme() {
168
+ if (typeof document === 'undefined') return null;
169
+
170
+ for (const element of [document.documentElement, document.body]) {
171
+ const theme = readThemeHint(element);
172
+ if (theme) return theme;
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ function readThemeHint(element) {
179
+ if (!element || typeof window.getComputedStyle !== 'function') {
180
+ return null;
181
+ }
182
+
183
+ const explicit = readExplicitTheme(element);
184
+ if (explicit) return explicit;
185
+
186
+ const styles = window.getComputedStyle(element);
187
+ const scheme = parseColorScheme(styles.colorScheme);
188
+ if (scheme) return scheme;
189
+
190
+ return parseBackgroundTheme(styles.backgroundColor);
191
+ }
192
+
193
+ function readExplicitTheme(element) {
194
+ const attrTheme = element.getAttribute('data-theme');
195
+ if (attrTheme === 'light' || attrTheme === 'dark') {
196
+ return attrTheme;
197
+ }
198
+
199
+ const className = element.className;
200
+ if (typeof className !== 'string') return null;
201
+
202
+ if (/\bdark\b|theme-dark|dark-theme/i.test(className)) {
203
+ return 'dark';
204
+ }
205
+
206
+ if (/\blight\b|theme-light|light-theme/i.test(className)) {
207
+ return 'light';
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ function parseColorScheme(value) {
214
+ if (!value || value === 'normal') return null;
215
+
216
+ const normalized = value.toLowerCase();
217
+ const hasDark = normalized.includes('dark');
218
+ const hasLight = normalized.includes('light');
219
+
220
+ if (hasDark && !hasLight) return 'dark';
221
+ if (hasLight && !hasDark) return 'light';
222
+
223
+ return null;
224
+ }
225
+
226
+ function parseBackgroundTheme(value) {
227
+ if (!value || value === 'transparent') return null;
228
+
229
+ const match = value.match(/rgba?\(([^)]+)\)/i);
230
+ if (!match) return null;
231
+
232
+ const parts = match[1]
233
+ .split(',')
234
+ .slice(0, 3)
235
+ .map((part) => Number.parseFloat(part.trim()));
236
+
237
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
238
+ return null;
239
+ }
240
+
241
+ const [red, green, blue] = parts;
242
+ const brightness = (red * 299 + green * 587 + blue * 114) / 1000;
243
+
244
+ if (brightness <= 140) return 'dark';
245
+ if (brightness >= 180) return 'light';
246
+
247
+ return null;
248
+ }
249
+
250
+ function watchMediaQuery(query, listener) {
251
+ if (typeof window.matchMedia !== 'function') {
252
+ return null;
253
+ }
254
+
255
+ const mediaQuery = window.matchMedia(query);
256
+
257
+ if (typeof mediaQuery.addEventListener === 'function') {
258
+ mediaQuery.addEventListener('change', listener);
259
+ return () => {
260
+ mediaQuery.removeEventListener('change', listener);
261
+ };
262
+ }
263
+
264
+ if (typeof mediaQuery.addListener === 'function') {
265
+ mediaQuery.addListener(listener);
266
+ return () => {
267
+ mediaQuery.removeListener(listener);
268
+ };
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ const STYLES = `
275
+ :host {
276
+ --oa-bg: #0b0d11;
277
+ --oa-surface: #14161d;
278
+ --oa-border: #1f2230;
279
+ --oa-text: #e8e8ed;
280
+ --oa-text-muted: #7a7d8c;
281
+ --oa-accent: #4ae68a;
282
+ --oa-accent-dim: rgba(74, 230, 138, 0.12);
283
+ --oa-danger: #ef4444;
284
+ --oa-warn: #f59e0b;
285
+ --oa-radius: 16px;
286
+ --oa-font: 'DM Sans', system-ui, -apple-system,
287
+ sans-serif;
288
+ --oa-mono: 'Space Mono', monospace;
289
+
290
+ display: block;
291
+ font-family: var(--oa-font);
292
+ color: var(--oa-text);
293
+ line-height: 1.4;
294
+ }
295
+
296
+ :host([data-theme="light"]) {
297
+ --oa-bg: #f8f9fb;
298
+ --oa-surface: #ffffff;
299
+ --oa-border: #e2e4ea;
300
+ --oa-text: #1a1c24;
301
+ --oa-text-muted: #6b6e7b;
302
+ --oa-accent: #22c55e;
303
+ --oa-accent-dim: rgba(34, 197, 94, 0.1);
304
+ }
305
+
306
+ * { box-sizing: border-box; margin: 0; padding: 0; }
307
+
308
+ /* ── Checkbox ────────────────────────────────── */
309
+
310
+ .oa-checkbox {
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 10px;
314
+ padding: 10px 14px;
315
+ background: var(--oa-surface);
316
+ border: 2px solid var(--oa-border);
317
+ border-radius: var(--oa-radius);
318
+ cursor: pointer;
319
+ user-select: none;
320
+ transition: border-color 0.2s, box-shadow 0.2s;
321
+ }
322
+
323
+ .oa-checkbox:hover {
324
+ border-color: var(--oa-text-muted);
325
+ }
326
+
327
+ .oa-checkbox.oa-verified {
328
+ border-color: var(--oa-accent);
329
+ cursor: default;
330
+ }
331
+
332
+ .oa-checkbox.oa-failed {
333
+ border-color: var(--oa-danger);
334
+ }
335
+
336
+ .oa-checkbox.oa-retry {
337
+ border-color: var(--oa-danger);
338
+ }
339
+
340
+ .oa-checkbox.oa-expired {
341
+ border-color: var(--oa-warn);
342
+ }
343
+
344
+ .oa-checkbox.oa-loading {
345
+ align-items: center;
346
+ }
347
+
348
+ .oa-check-box {
349
+ width: 24px;
350
+ height: 24px;
351
+ border: 2px solid var(--oa-border);
352
+ border-radius: 6px;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ flex-shrink: 0;
357
+ transition: all 0.2s;
358
+ }
359
+
360
+ .oa-loading .oa-check-box {
361
+ border-color: transparent;
362
+ background: transparent;
363
+ }
364
+
365
+ .oa-loading .oa-check-box .oa-spinner {
366
+ width: 100%;
367
+ height: 100%;
368
+ display: flex;
369
+ align-items: center;
370
+ justify-content: center;
371
+ transform: translateY(2px);
372
+ }
373
+
374
+ .oa-verified .oa-check-box {
375
+ background: var(--oa-accent);
376
+ border-color: var(--oa-accent);
377
+ color: var(--oa-bg);
378
+ }
379
+
380
+ .oa-failed .oa-check-box {
381
+ background: var(--oa-danger);
382
+ border-color: var(--oa-danger);
383
+ color: white;
384
+ }
385
+
386
+ .oa-retry .oa-check-box {
387
+ background: var(--oa-danger);
388
+ border-color: var(--oa-danger);
389
+ color: white;
390
+ }
391
+
392
+ .oa-check-box svg {
393
+ width: 14px;
394
+ height: 14px;
395
+ }
396
+
397
+ .oa-check-box .oa-spinner svg {
398
+ width: 18px;
399
+ height: 18px;
400
+ }
401
+
402
+ .oa-label {
403
+ display: flex;
404
+ flex-direction: column;
405
+ gap: 1px;
406
+ flex: 1;
407
+ min-width: 0;
408
+ }
409
+
410
+ .oa-label-text {
411
+ font-size: 13px;
412
+ font-weight: 600;
413
+ }
414
+
415
+ .oa-right-section {
416
+ display: flex;
417
+ flex-direction: column;
418
+ align-items: center;
419
+ gap: 2px;
420
+ flex-shrink: 0;
421
+ margin-left: auto;
422
+ }
423
+
424
+ .oa-face-icon-wrap {
425
+ width: 18px;
426
+ height: 22px;
427
+ color: var(--oa-text-muted);
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: center;
431
+ flex-shrink: 0;
432
+ }
433
+
434
+ .oa-face-icon-wrap .oa-face-icon-svg {
435
+ width: 100%;
436
+ height: 100%;
437
+ }
438
+
439
+ .oa-face-icon-wrap .oa-eye {
440
+ transform-box: fill-box;
441
+ transform-origin: center;
442
+ animation: oa-idle-blink 5s ease-in-out infinite;
443
+ }
444
+
445
+ .oa-face-icon-wrap .oa-face-icon-svg {
446
+ animation: oa-breathe 4s ease-in-out infinite;
447
+ }
448
+
449
+ .oa-branding-row {
450
+ display: flex;
451
+ align-items: center;
452
+ justify-content: center;
453
+ gap: 4px;
454
+ }
455
+
456
+ .oa-branding-link {
457
+ font-size: 10px;
458
+ font-weight: 700;
459
+ font-family: var(--oa-mono);
460
+ color: var(--oa-text-muted);
461
+ text-decoration: none;
462
+ letter-spacing: -0.02em;
463
+ cursor: pointer;
464
+ }
465
+
466
+ .oa-branding-link:hover {
467
+ color: var(--oa-text);
468
+ }
469
+
470
+ .oa-links-row {
471
+ display: flex;
472
+ gap: 6px;
473
+ justify-content: center;
474
+ }
475
+
476
+ .oa-links-row a {
477
+ font-size: 9px;
478
+ color: var(--oa-text-muted);
479
+ text-decoration: none;
480
+ opacity: 0.7;
481
+ cursor: pointer;
482
+ }
483
+
484
+ .oa-links-row a:hover {
485
+ opacity: 1;
486
+ text-decoration: underline;
487
+ }
488
+
489
+ .oa-compact .oa-label-text {
490
+ font-size: 11px;
491
+ }
492
+
493
+ .oa-compact .oa-face-icon-wrap {
494
+ width: 16px;
495
+ height: 20px;
496
+ }
497
+
498
+ /* ── Error banner (inline in checkbox) ───────── */
499
+
500
+ .oa-error-banner {
501
+ display: flex;
502
+ align-items: center;
503
+ gap: 8px;
504
+ padding: 8px 12px;
505
+ margin-top: 6px;
506
+ background: rgba(239, 68, 68, 0.08);
507
+ border: 1px solid rgba(239, 68, 68, 0.2);
508
+ border-radius: 8px;
509
+ font-size: 12px;
510
+ color: var(--oa-danger);
511
+ animation: oa-fade-in 0.3s ease;
512
+ }
513
+
514
+ .oa-error-banner button {
515
+ margin-left: auto;
516
+ background: none;
517
+ border: 1px solid var(--oa-danger);
518
+ border-radius: 6px;
519
+ color: var(--oa-danger);
520
+ font-size: 11px;
521
+ font-weight: 600;
522
+ padding: 3px 10px;
523
+ cursor: pointer;
524
+ font-family: var(--oa-font);
525
+ flex-shrink: 0;
526
+ }
527
+
528
+ .oa-error-banner button:hover {
529
+ background: rgba(239, 68, 68, 0.1);
530
+ }
531
+
532
+ /* ── Popup / Modal shell ─────────────────────── */
533
+
534
+ .oa-popup {
535
+ position: fixed;
536
+ z-index: 100000;
537
+ background: var(--oa-bg);
538
+ border: 1px solid var(--oa-border);
539
+ border-radius: var(--oa-radius);
540
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4),
541
+ 0 0 0 1px rgba(0,0,0,0.08);
542
+ width: 340px;
543
+ max-height: 90vh;
544
+ overflow: hidden;
545
+ animation: oa-popup-in 0.25s ease;
546
+ display: flex;
547
+ flex-direction: column;
548
+ }
549
+
550
+ .oa-modal-overlay {
551
+ position: fixed;
552
+ inset: 0;
553
+ z-index: 99999;
554
+ background: rgba(0,0,0,0.65);
555
+ display: flex;
556
+ align-items: center;
557
+ justify-content: center;
558
+ animation: oa-fade-in 0.2s ease;
559
+ backdrop-filter: blur(6px);
560
+ }
561
+
562
+ .oa-modal {
563
+ background: var(--oa-bg);
564
+ border: 1px solid var(--oa-border);
565
+ border-radius: var(--oa-radius);
566
+ width: 360px;
567
+ max-width: 95vw;
568
+ max-height: 95vh;
569
+ overflow: hidden;
570
+ animation: oa-slide-in 0.3s ease;
571
+ display: flex;
572
+ flex-direction: column;
573
+ }
574
+
575
+ /* ── Header ──────────────────────────────────── */
576
+
577
+ .oa-header {
578
+ display: flex;
579
+ align-items: center;
580
+ justify-content: space-between;
581
+ padding: 10px 14px;
582
+ border-bottom: 1px solid var(--oa-border);
583
+ flex-shrink: 0;
584
+ }
585
+
586
+ .oa-logo {
587
+ font-family: var(--oa-mono);
588
+ font-size: 0.85rem;
589
+ letter-spacing: -0.03em;
590
+ color: var(--oa-text-muted);
591
+ text-decoration: none;
592
+ cursor: pointer;
593
+ }
594
+
595
+ .oa-logo:hover {
596
+ color: var(--oa-text);
597
+ }
598
+
599
+ .oa-logo strong {
600
+ color: var(--oa-text);
601
+ font-weight: 700;
602
+ }
603
+
604
+ .oa-badge {
605
+ font-size: 0.55rem;
606
+ font-weight: 600;
607
+ text-transform: uppercase;
608
+ letter-spacing: 0.08em;
609
+ color: var(--oa-accent);
610
+ background: var(--oa-accent-dim);
611
+ padding: 2px 6px;
612
+ border-radius: 100px;
613
+ margin-left: 6px;
614
+ }
615
+
616
+ .oa-title {
617
+ display: flex;
618
+ align-items: center;
619
+ gap: 6px;
620
+ }
621
+
622
+ .oa-close-btn {
623
+ width: 28px;
624
+ height: 28px;
625
+ border: none;
626
+ background: none;
627
+ cursor: pointer;
628
+ display: flex;
629
+ align-items: center;
630
+ justify-content: center;
631
+ border-radius: 8px;
632
+ color: var(--oa-text-muted);
633
+ transition: background 0.15s, color 0.15s;
634
+ }
635
+
636
+ .oa-close-btn:hover {
637
+ background: var(--oa-border);
638
+ color: var(--oa-text);
639
+ }
640
+
641
+ .oa-close-btn svg {
642
+ width: 16px;
643
+ height: 16px;
644
+ }
645
+
646
+ /* ── Body (viewport) ─────────────────────────── */
647
+
648
+ .oa-body {
649
+ flex: 1;
650
+ position: relative;
651
+ overflow: hidden;
652
+ }
653
+
654
+ /* ── Hero / start screen ─────────────────────── */
655
+
656
+ .oa-hero {
657
+ display: flex;
658
+ flex-direction: column;
659
+ align-items: center;
660
+ justify-content: center;
661
+ gap: 1rem;
662
+ padding: 2rem 1.2rem 1.2rem;
663
+ background: var(--oa-surface);
664
+ border: 1px solid var(--oa-border);
665
+ border-radius: var(--oa-radius);
666
+ margin: 10px;
667
+ animation: oa-fade-in 0.4s ease;
668
+ }
669
+
670
+ .oa-hero-icon {
671
+ width: 80px;
672
+ height: 96px;
673
+ color: var(--oa-text-muted);
674
+ }
675
+
676
+ .oa-hero-icon .oa-face-svg.oa-idle {
677
+ animation: oa-breathe 4s ease-in-out infinite;
678
+ }
679
+
680
+ .oa-hero-icon .oa-eye {
681
+ transform-box: fill-box;
682
+ transform-origin: center;
683
+ animation: oa-idle-blink 5s ease-in-out infinite;
684
+ }
685
+
686
+ .oa-hero-status {
687
+ color: var(--oa-text-muted);
688
+ font-size: 0.8rem;
689
+ font-weight: 500;
690
+ text-align: center;
691
+ min-height: 1.4em;
692
+ }
693
+
694
+ .oa-hero-privacy {
695
+ font-size: 0.68rem;
696
+ color: var(--oa-text-muted);
697
+ text-align: center;
698
+ line-height: 1.5;
699
+ max-width: 260px;
700
+ opacity: 0.7;
701
+ }
702
+
703
+ .oa-hero-privacy svg {
704
+ width: 10px;
705
+ height: 10px;
706
+ vertical-align: -2px;
707
+ margin-right: 2px;
708
+ }
709
+
710
+ /* ── Start / Retry button ────────────────────── */
711
+
712
+ .oa-actions {
713
+ display: flex;
714
+ justify-content: center;
715
+ padding: 0 14px 14px;
716
+ flex-shrink: 0;
717
+ }
718
+
719
+ .oa-btn {
720
+ font-family: var(--oa-font);
721
+ padding: 0.55rem 1.5rem;
722
+ border: none;
723
+ border-radius: 10px;
724
+ font-size: 0.8rem;
725
+ font-weight: 600;
726
+ cursor: pointer;
727
+ background: var(--oa-accent);
728
+ color: var(--oa-bg);
729
+ transition: transform 0.12s, opacity 0.15s;
730
+ width: 100%;
731
+ max-width: 240px;
732
+ }
733
+
734
+ .oa-btn:hover { opacity: 0.88; }
735
+ .oa-btn:active { transform: scale(0.97); }
736
+
737
+ /* ── Video area ──────────────────────────────── */
738
+
739
+ .oa-video-area {
740
+ position: relative;
741
+ width: 100%;
742
+ aspect-ratio: 3/4;
743
+ max-height: 55vh;
744
+ background: var(--oa-surface);
745
+ overflow: hidden;
746
+ border-radius: var(--oa-radius);
747
+ margin: 10px;
748
+ width: calc(100% - 20px);
749
+ border: 1px solid var(--oa-border);
750
+ animation: oa-fade-in 0.3s ease;
751
+ }
752
+
753
+ .oa-video-area video {
754
+ width: 100%;
755
+ height: 100%;
756
+ object-fit: cover;
757
+ transform: scaleX(-1);
758
+ }
759
+
760
+ /* ── Face guide overlay ──────────────────────── */
761
+
762
+ .oa-face-guide {
763
+ position: absolute;
764
+ top: 50%;
765
+ left: 50%;
766
+ transform: translate(-50%, -50%);
767
+ width: 80%;
768
+ max-width: 360px;
769
+ aspect-ratio: 5/6;
770
+ perspective: 400px;
771
+ color: rgba(255, 255, 255, 0.45);
772
+ pointer-events: none;
773
+ z-index: 2;
774
+ transition: opacity 0.4s ease;
775
+ }
776
+
777
+ .oa-face-guide .oa-face-svg {
778
+ width: 100%;
779
+ height: 100%;
780
+ transform-origin: center center;
781
+ filter: drop-shadow(
782
+ 0 0 16px rgba(15, 23, 42, 0.16)
783
+ );
784
+ }
785
+
786
+ .oa-face-guide .oa-eye {
787
+ transform-box: fill-box;
788
+ transform-origin: center;
789
+ }
790
+
791
+ .oa-face-guide[data-task="turn-left"] .oa-face-svg {
792
+ animation: oa-turn-left 2s ease-in-out infinite;
793
+ }
794
+ .oa-face-guide[data-task="turn-right"] .oa-face-svg {
795
+ animation: oa-turn-right 2s ease-in-out infinite;
796
+ }
797
+ .oa-face-guide[data-task="nod"] .oa-face-svg {
798
+ animation: oa-nod 2s ease-in-out infinite;
799
+ }
800
+ .oa-face-guide[data-task="blink-twice"] .oa-eye {
801
+ animation: oa-blink 2.4s ease-in-out infinite;
802
+ }
803
+ .oa-face-guide[data-task="move-closer"] .oa-face-svg {
804
+ animation: oa-closer 2.5s ease-in-out infinite;
805
+ }
806
+
807
+ /* ── Challenge HUD (bottom gradient overlay) ── */
808
+
809
+ .oa-challenge-hud {
810
+ position: absolute;
811
+ bottom: 0;
812
+ left: 0;
813
+ right: 0;
814
+ padding: 1rem 0.8rem 0.6rem;
815
+ background: linear-gradient(
816
+ to top,
817
+ rgba(11, 13, 17, 0.92) 0%,
818
+ rgba(11, 13, 17, 0) 100%
819
+ );
820
+ z-index: 3;
821
+ pointer-events: none;
822
+ }
823
+
824
+ .oa-challenge-text {
825
+ font-size: 0.85rem;
826
+ font-weight: 600;
827
+ text-align: center;
828
+ color: var(--oa-text);
829
+ text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
830
+ margin-bottom: 0.4rem;
831
+ }
832
+
833
+ .oa-challenge-bar {
834
+ height: 3px;
835
+ background: rgba(255, 255, 255, 0.1);
836
+ border-radius: 2px;
837
+ overflow: hidden;
838
+ }
839
+
840
+ .oa-challenge-fill {
841
+ height: 100%;
842
+ background: var(--oa-accent);
843
+ border-radius: 2px;
844
+ transition: width 0.25s ease;
845
+ }
846
+
847
+ /* ── Video status (top gradient overlay) ─────── */
848
+
849
+ .oa-video-status {
850
+ position: absolute;
851
+ top: 0;
852
+ left: 0;
853
+ right: 0;
854
+ padding: 0.8rem 0.8rem 1.2rem;
855
+ background: linear-gradient(
856
+ to bottom,
857
+ rgba(11, 13, 17, 0.85) 0%,
858
+ rgba(11, 13, 17, 0) 100%
859
+ );
860
+ z-index: 3;
861
+ pointer-events: none;
862
+ }
863
+
864
+ .oa-video-status p {
865
+ font-size: 0.75rem;
866
+ font-weight: 500;
867
+ text-align: center;
868
+ color: var(--oa-text-muted);
869
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
870
+ }
871
+
872
+ /* ── Result area ─────────────────────────────── */
873
+
874
+ .oa-result {
875
+ display: flex;
876
+ flex-direction: column;
877
+ align-items: center;
878
+ gap: 0.5rem;
879
+ padding: 2rem 1.2rem;
880
+ background: var(--oa-surface);
881
+ border: 1px solid var(--oa-border);
882
+ border-radius: var(--oa-radius);
883
+ margin: 10px;
884
+ animation: oa-fade-in 0.4s ease;
885
+ }
886
+
887
+ .oa-result-icon {
888
+ font-size: 2.4rem;
889
+ line-height: 1;
890
+ }
891
+
892
+ .oa-result-text {
893
+ font-size: 0.85rem;
894
+ font-weight: 500;
895
+ text-align: center;
896
+ }
897
+
898
+ .oa-result-pass { color: var(--oa-accent); }
899
+ .oa-result-fail { color: var(--oa-danger); }
900
+ .oa-result-retry { color: var(--oa-warn); }
901
+
902
+ .oa-hidden { display: none !important; }
903
+
904
+ /* ── Animations ──────────────────────────────── */
905
+
906
+ @keyframes oa-popup-in {
907
+ from { opacity: 0; transform: scale(0.95); }
908
+ to { opacity: 1; transform: scale(1); }
909
+ }
910
+
911
+ @keyframes oa-fade-in {
912
+ from { opacity: 0; transform: translateY(6px); }
913
+ to { opacity: 1; transform: translateY(0); }
914
+ }
915
+
916
+ @keyframes oa-slide-in {
917
+ from { opacity: 0; transform: translateY(16px); }
918
+ to { opacity: 1; transform: translateY(0); }
919
+ }
920
+
921
+ @keyframes oa-breathe {
922
+ 0%, 100% { transform: scale(1); }
923
+ 50% { transform: scale(1.04); }
924
+ }
925
+
926
+ @keyframes oa-idle-blink {
927
+ 0%, 42%, 48%, 100% { transform: scaleY(1); }
928
+ 44%, 46% { transform: scaleY(0.05); }
929
+ }
930
+
931
+ @keyframes oa-turn-left {
932
+ 0%, 100% { transform: rotateY(0); }
933
+ 35%, 65% { transform: rotateY(-30deg); }
934
+ }
935
+
936
+ @keyframes oa-turn-right {
937
+ 0%, 100% { transform: rotateY(0); }
938
+ 35%, 65% { transform: rotateY(30deg); }
939
+ }
940
+
941
+ @keyframes oa-nod {
942
+ 0%, 100% { transform: rotateX(0); }
943
+ 30%, 50% { transform: rotateX(25deg); }
944
+ }
945
+
946
+ @keyframes oa-blink {
947
+ 0%, 18%, 32%, 50%, 100% { transform: scaleY(1); }
948
+ 22%, 28% { transform: scaleY(0.05); }
949
+ 40%, 46% { transform: scaleY(0.05); }
950
+ }
951
+
952
+ @keyframes oa-closer {
953
+ 0%, 100% { transform: scale(1); }
954
+ 35%, 55% { transform: scale(1.3); }
955
+ }
956
+ `;
957
+
958
+ function checkboxTemplate(labelText) {
959
+ return `
960
+ <div class="oa-widget-wrap">
961
+ <div class="oa-checkbox" role="checkbox"
962
+ aria-checked="false" tabindex="0">
963
+ <div class="oa-check-box"></div>
964
+ <div class="oa-label">
965
+ <span class="oa-label-text">${labelText}</span>
966
+ </div>
967
+ <div class="oa-right-section">
968
+ <div class="oa-branding-row">
969
+ <div class="oa-face-icon-wrap">
970
+ ${FACE_ICON_SVG}
971
+ </div>
972
+ <a class="oa-branding-link"
973
+ href="https://github.com/tn3w/OpenAge"
974
+ target="_blank" rel="noopener">OpenAge</a>
975
+ </div>
976
+ <div class="oa-links-row">
977
+ <a href="https://github.com/tn3w/OpenAge"
978
+ target="_blank"
979
+ rel="noopener">Terms</a>
980
+ <a href="https://github.com/tn3w/OpenAge"
981
+ target="_blank"
982
+ rel="noopener">Privacy</a>
983
+ </div>
984
+ </div>
985
+ </div>
986
+ <div class="oa-error-slot"></div>
987
+ </div>
988
+ `;
989
+ }
990
+
991
+ function heroTemplate(statusText) {
992
+ return `
993
+ <div class="oa-hero">
994
+ <div class="oa-hero-icon">
995
+ ${FACE_SVG.replace('class="oa-face-svg"', 'class="oa-face-svg oa-idle"')}
996
+ </div>
997
+ <p class="oa-hero-status">${statusText}</p>
998
+ <p class="oa-hero-privacy">
999
+ ${SHIELD_SVG}
1000
+ Open-source &amp; privacy-focused.
1001
+ No photos or camera data leave your device.
1002
+ </p>
1003
+ </div>
1004
+ `;
1005
+ }
1006
+
1007
+ function challengeTemplate() {
1008
+ return `
1009
+ <div class="oa-video-area">
1010
+ <video autoplay playsinline muted></video>
1011
+ <div class="oa-face-guide">
1012
+ ${FACE_GUIDE_SVG}
1013
+ </div>
1014
+ <div class="oa-challenge-hud oa-hidden">
1015
+ <p class="oa-challenge-text"></p>
1016
+ <div class="oa-challenge-bar">
1017
+ <div class="oa-challenge-fill"
1018
+ style="width:0%"></div>
1019
+ </div>
1020
+ </div>
1021
+ <div class="oa-video-status">
1022
+ <p></p>
1023
+ </div>
1024
+ </div>
1025
+ `;
1026
+ }
1027
+
1028
+ function resultTemplate(outcome, message) {
1029
+ const icons = {
1030
+ fail: '✕',
1031
+ retry: '↻',
1032
+ };
1033
+ const classes = {
1034
+ fail: 'oa-result-fail',
1035
+ retry: 'oa-result-retry',
1036
+ };
1037
+ return `
1038
+ <div class="oa-result ${classes[outcome] || ''}">
1039
+ <div class="oa-result-icon">
1040
+ ${icons[outcome] || '?'}
1041
+ </div>
1042
+ <div class="oa-result-text">${message}</div>
1043
+ </div>
1044
+ `;
1045
+ }
1046
+
1047
+ const VERSION = '1.0.0';
1048
+
1049
+ const MEDIAPIPE_CDN = 'https://cdn.jsdelivr.net/npm/' + '@mediapipe/tasks-vision@0.10.17';
1050
+
1051
+ const MEDIAPIPE_WASM = `${MEDIAPIPE_CDN}/wasm`;
1052
+
1053
+ const MEDIAPIPE_VISION = `${MEDIAPIPE_CDN}/vision_bundle.mjs`;
1054
+
1055
+ const MEDIAPIPE_MODEL =
1056
+ 'https://storage.googleapis.com/mediapipe-models/' +
1057
+ 'face_landmarker/face_landmarker/float16/latest/' +
1058
+ 'face_landmarker.task';
1059
+
1060
+ const FACEAPI_CDN =
1061
+ 'https://cdn.jsdelivr.net/npm/' + 'face-api.js@0.22.2/dist/face-api.min.js';
1062
+
1063
+ const FACEAPI_MODEL_CDN = 'https://cdn.jsdelivr.net/npm/' + 'face-api.js@0.22.2/weights';
1064
+
1065
+ const MAX_RETRIES = 3;
1066
+ const BURST_FRAMES = 5;
1067
+ const BURST_INTERVAL_MS = 200;
1068
+ const POSITION_CHECK_MS = 100;
1069
+ const MOTION_CAPTURE_MS = 3000;
1070
+ const MOTION_SAMPLE_MS = 100;
1071
+ const TOKEN_EXPIRY_S = 300;
1072
+ const STABLE_FRAMES_REQUIRED = 10;
1073
+
1074
+ const TASK_TIMEOUT_MS = 8000;
1075
+ const MIN_TASK_TIME_MS = 500;
1076
+ const TASK_COUNT = 3;
1077
+ const REQUIRED_TASK_PASSES = 2;
1078
+
1079
+ const POPUP_MIN_WIDTH = 340;
1080
+ const POPUP_MIN_HEIGHT = 520;
1081
+ const POPUP_MARGIN = 12;
1082
+
1083
+ let widgetCounter = 0;
1084
+
1085
+ class Widget {
1086
+ constructor(container, params) {
1087
+ this.id = `oa-${++widgetCounter}`;
1088
+ this.params = params;
1089
+ this.container = resolveContainer(container);
1090
+ this.anchorElement = null;
1091
+ this.state = 'idle';
1092
+ this.token = null;
1093
+ this.popup = null;
1094
+ this.shadow = null;
1095
+ this.elements = {};
1096
+ this.onChallenge = null;
1097
+ this.onStartClick = null;
1098
+ this.popupFrame = 0;
1099
+ this.themeCleanup = null;
1100
+
1101
+ this.render();
1102
+ }
1103
+
1104
+ render() {
1105
+ const host = document.createElement('div');
1106
+ host.id = this.id;
1107
+ this.shadow = host.attachShadow({ mode: 'open' });
1108
+
1109
+ const style = document.createElement('style');
1110
+ style.textContent = STYLES;
1111
+ this.shadow.appendChild(style);
1112
+
1113
+ const theme = resolveTheme(this.params.theme);
1114
+ host.setAttribute('data-theme', theme);
1115
+ this.themeCleanup = watchTheme(host, this.params.theme);
1116
+
1117
+ if (this.params.size === 'invisible') {
1118
+ host.style.display = 'none';
1119
+ this.container.appendChild(host);
1120
+ this.host = host;
1121
+ return;
1122
+ }
1123
+
1124
+ const label = 'I am of age';
1125
+
1126
+ const wrapper = document.createElement('div');
1127
+ wrapper.innerHTML = checkboxTemplate(label);
1128
+ this.shadow.appendChild(wrapper.firstElementChild);
1129
+
1130
+ const checkbox = this.shadow.querySelector('.oa-checkbox');
1131
+
1132
+ if (this.params.size === 'compact') {
1133
+ checkbox.classList.add('oa-compact');
1134
+ }
1135
+
1136
+ checkbox.addEventListener('click', (event) => {
1137
+ if (event.target.closest('a')) return;
1138
+ if (this.state === 'verified') return;
1139
+ if (this.state === 'loading') return;
1140
+ this.clearError();
1141
+ this.startChallenge();
1142
+ });
1143
+
1144
+ checkbox.addEventListener('keydown', (event) => {
1145
+ if (event.key === 'Enter' || event.key === ' ') {
1146
+ event.preventDefault();
1147
+ checkbox.click();
1148
+ }
1149
+ });
1150
+
1151
+ this.elements.checkbox = checkbox;
1152
+ this.elements.checkBox = this.shadow.querySelector('.oa-check-box');
1153
+ this.elements.errorSlot = this.shadow.querySelector('.oa-error-slot');
1154
+
1155
+ this.container.appendChild(host);
1156
+ this.host = host;
1157
+ }
1158
+
1159
+ startChallenge() {
1160
+ this.setState('loading');
1161
+
1162
+ if (this.onChallenge) {
1163
+ this.onChallenge(this);
1164
+ }
1165
+ }
1166
+
1167
+ createPopupShell() {
1168
+ const theme = resolveTheme(this.params.theme);
1169
+ const popupHost = document.createElement('div');
1170
+ popupHost.setAttribute('data-theme', theme);
1171
+ const popupShadow = popupHost.attachShadow({
1172
+ mode: 'open',
1173
+ });
1174
+
1175
+ const style = document.createElement('style');
1176
+ style.textContent = STYLES;
1177
+ popupShadow.appendChild(style);
1178
+
1179
+ const themeCleanup = watchTheme(popupHost, this.params.theme);
1180
+
1181
+ return {
1182
+ popupHost,
1183
+ popupShadow,
1184
+ themeCleanup,
1185
+ };
1186
+ }
1187
+
1188
+ openPopup() {
1189
+ if (this.popup) return this.getVideo();
1190
+
1191
+ const anchor = this.getPopupAnchor();
1192
+
1193
+ if (!anchor) {
1194
+ return this.openModal();
1195
+ }
1196
+
1197
+ const { popupHost, popupShadow, themeCleanup } = this.createPopupShell();
1198
+
1199
+ const popup = document.createElement('div');
1200
+ popup.className = 'oa-popup';
1201
+ popup.innerHTML = this.buildPopupContent();
1202
+ popup.style.visibility = 'hidden';
1203
+ popup.style.pointerEvents = 'none';
1204
+
1205
+ popupShadow.appendChild(popup);
1206
+ document.body.appendChild(popupHost);
1207
+
1208
+ this.popup = {
1209
+ anchor,
1210
+ host: popupHost,
1211
+ root: popup,
1212
+ themeCleanup,
1213
+ };
1214
+
1215
+ const anchorRect = anchor.getBoundingClientRect();
1216
+ const popupRect = popup.getBoundingClientRect();
1217
+ const position = findPopupPosition(anchorRect, popupRect);
1218
+
1219
+ if (position.mode === 'modal') {
1220
+ popupHost.remove();
1221
+ themeCleanup?.();
1222
+ this.popup = null;
1223
+ return this.openModal();
1224
+ }
1225
+
1226
+ this.bindPopupEvents(popup, popupShadow);
1227
+ this.updatePopupPosition();
1228
+ this.startPopupTracking();
1229
+ return this.getVideo();
1230
+ }
1231
+
1232
+ openModal() {
1233
+ const { popupHost, popupShadow, themeCleanup } = this.createPopupShell();
1234
+
1235
+ const overlay = document.createElement('div');
1236
+ overlay.className = 'oa-modal-overlay';
1237
+
1238
+ const modal = document.createElement('div');
1239
+ modal.className = 'oa-modal';
1240
+ modal.innerHTML = this.buildPopupContent();
1241
+
1242
+ overlay.appendChild(modal);
1243
+ popupShadow.appendChild(overlay);
1244
+ document.body.appendChild(popupHost);
1245
+
1246
+ overlay.addEventListener('click', (event) => {
1247
+ if (event.target === overlay) this.closePopup();
1248
+ });
1249
+
1250
+ this.popup = {
1251
+ host: popupHost,
1252
+ root: modal,
1253
+ overlay,
1254
+ themeCleanup,
1255
+ };
1256
+ this.bindPopupEvents(modal, popupShadow);
1257
+ return this.getVideo();
1258
+ }
1259
+
1260
+ getPopupAnchor() {
1261
+ return this.anchorElement || this.elements.checkbox || this.host || null;
1262
+ }
1263
+
1264
+ startPopupTracking() {
1265
+ if (!this.popup || this.popup.overlay) return;
1266
+
1267
+ const schedule = () => {
1268
+ this.schedulePopupPosition();
1269
+ };
1270
+
1271
+ const cleanups = [];
1272
+ const addWindowListener = (name, options) => {
1273
+ window.addEventListener(name, schedule, options);
1274
+ cleanups.push(() => {
1275
+ window.removeEventListener(name, schedule, options);
1276
+ });
1277
+ };
1278
+
1279
+ addWindowListener('resize', { passive: true });
1280
+ addWindowListener('scroll', {
1281
+ capture: true,
1282
+ passive: true,
1283
+ });
1284
+
1285
+ if (window.visualViewport) {
1286
+ const viewport = window.visualViewport;
1287
+ viewport.addEventListener('resize', schedule);
1288
+ viewport.addEventListener('scroll', schedule);
1289
+ cleanups.push(() => {
1290
+ viewport.removeEventListener('resize', schedule);
1291
+ viewport.removeEventListener('scroll', schedule);
1292
+ });
1293
+ }
1294
+
1295
+ if (typeof ResizeObserver === 'function') {
1296
+ const observer = new ResizeObserver(() => {
1297
+ schedule();
1298
+ });
1299
+ observer.observe(this.popup.root);
1300
+ observer.observe(this.popup.anchor);
1301
+ observer.observe(document.documentElement);
1302
+ if (document.body) {
1303
+ observer.observe(document.body);
1304
+ }
1305
+ cleanups.push(() => observer.disconnect());
1306
+ }
1307
+
1308
+ this.popup.cleanup = () => {
1309
+ if (this.popupFrame) {
1310
+ cancelAnimationFrame(this.popupFrame);
1311
+ this.popupFrame = 0;
1312
+ }
1313
+ for (const cleanup of cleanups) {
1314
+ cleanup();
1315
+ }
1316
+ };
1317
+ }
1318
+
1319
+ schedulePopupPosition() {
1320
+ if (!this.popup || this.popup.overlay) return;
1321
+ if (this.popupFrame) return;
1322
+
1323
+ this.popupFrame = requestAnimationFrame(() => {
1324
+ this.popupFrame = 0;
1325
+ this.updatePopupPosition();
1326
+ });
1327
+ }
1328
+
1329
+ updatePopupPosition() {
1330
+ if (!this.popup || this.popup.overlay) return;
1331
+
1332
+ const anchor = this.getPopupAnchor();
1333
+ if (!anchor || !anchor.isConnected) {
1334
+ this.closePopup();
1335
+ return;
1336
+ }
1337
+
1338
+ this.popup.anchor = anchor;
1339
+
1340
+ const anchorRect = anchor.getBoundingClientRect();
1341
+ const popupRect = this.popup.root.getBoundingClientRect();
1342
+ const position = findPopupPosition(anchorRect, popupRect);
1343
+
1344
+ if (position.mode === 'modal') {
1345
+ this.closePopup();
1346
+ this.openModal();
1347
+ return;
1348
+ }
1349
+
1350
+ const top = `${Math.round(position.top)}px`;
1351
+ const left = `${Math.round(position.left)}px`;
1352
+
1353
+ if (this.popup.root.style.top !== top) {
1354
+ this.popup.root.style.top = top;
1355
+ }
1356
+
1357
+ if (this.popup.root.style.left !== left) {
1358
+ this.popup.root.style.left = left;
1359
+ }
1360
+
1361
+ this.popup.root.dataset.placement = position.placement;
1362
+ this.popup.root.style.visibility = 'visible';
1363
+ this.popup.root.style.pointerEvents = 'auto';
1364
+ }
1365
+
1366
+ buildPopupContent() {
1367
+ return `
1368
+ <div class="oa-header">
1369
+ <div class="oa-title">
1370
+ <a class="oa-logo"
1371
+ href="https://github.com/tn3w/OpenAge"
1372
+ target="_blank" rel="noopener">
1373
+ Open<strong>Age</strong>
1374
+ </a>
1375
+ <span class="oa-badge">on-device</span>
1376
+ </div>
1377
+ <button class="oa-close-btn"
1378
+ aria-label="Close">
1379
+ ${CLOSE_SVG}
1380
+ </button>
1381
+ </div>
1382
+ <div class="oa-body">
1383
+ ${heroTemplate('Initializing…')}
1384
+ </div>
1385
+ <div class="oa-actions oa-hidden">
1386
+ <button class="oa-btn oa-start-btn">
1387
+ Begin Verification
1388
+ </button>
1389
+ </div>
1390
+ `;
1391
+ }
1392
+
1393
+ bindPopupEvents(root, shadow) {
1394
+ const closeBtn = root.querySelector('.oa-close-btn');
1395
+ if (closeBtn) {
1396
+ closeBtn.addEventListener('click', () => {
1397
+ this.closePopup();
1398
+ this.params.closeCallback?.();
1399
+ });
1400
+ }
1401
+
1402
+ const startBtn = root.querySelector('.oa-start-btn');
1403
+ if (startBtn) {
1404
+ startBtn.addEventListener('click', () => {
1405
+ if (this.onStartClick) this.onStartClick();
1406
+ });
1407
+ }
1408
+
1409
+ this.popupElements = {
1410
+ body: root.querySelector('.oa-body'),
1411
+ actions: root.querySelector('.oa-actions'),
1412
+ startBtn,
1413
+ heroStatus: root.querySelector('.oa-hero-status'),
1414
+ };
1415
+ }
1416
+
1417
+ getVideo() {
1418
+ if (!this.popup) return null;
1419
+ return this.popup.root.querySelector('video');
1420
+ }
1421
+
1422
+ showHero(statusText) {
1423
+ if (!this.popupElements?.body) return;
1424
+ this.popupElements.body.innerHTML = heroTemplate(statusText);
1425
+ this.popupElements.heroStatus = this.popupElements.body.querySelector('.oa-hero-status');
1426
+ this.hideActions();
1427
+ }
1428
+
1429
+ showReady() {
1430
+ this.setHeroStatus('Ready to verify your age.');
1431
+ this.showActions('Begin Verification');
1432
+ }
1433
+
1434
+ showCamera() {
1435
+ if (!this.popupElements?.body) return;
1436
+ this.popupElements.body.innerHTML = challengeTemplate();
1437
+
1438
+ this.popupElements.video = this.popupElements.body.querySelector('video');
1439
+ this.popupElements.faceGuide = this.popupElements.body.querySelector('.oa-face-guide');
1440
+ this.popupElements.challengeHud =
1441
+ this.popupElements.body.querySelector('.oa-challenge-hud');
1442
+ this.popupElements.challengeText =
1443
+ this.popupElements.body.querySelector('.oa-challenge-text');
1444
+ this.popupElements.challengeFill =
1445
+ this.popupElements.body.querySelector('.oa-challenge-fill');
1446
+ this.popupElements.videoStatus =
1447
+ this.popupElements.body.querySelector('.oa-video-status p');
1448
+
1449
+ this.hideActions();
1450
+ return this.popupElements.video;
1451
+ }
1452
+
1453
+ showLiveness() {
1454
+ if (this.popupElements?.faceGuide) {
1455
+ this.popupElements.faceGuide.classList.remove('oa-hidden');
1456
+ }
1457
+ if (this.popupElements?.challengeHud) {
1458
+ this.popupElements.challengeHud.classList.remove('oa-hidden');
1459
+ }
1460
+ }
1461
+
1462
+ setHeroStatus(text) {
1463
+ if (this.popupElements?.heroStatus) {
1464
+ this.popupElements.heroStatus.textContent = text;
1465
+ }
1466
+ }
1467
+
1468
+ setVideoStatus(text) {
1469
+ if (this.popupElements?.videoStatus) {
1470
+ this.popupElements.videoStatus.textContent = text;
1471
+ }
1472
+ }
1473
+
1474
+ setInstruction(text) {
1475
+ if (this.popupElements?.challengeText) {
1476
+ this.popupElements.challengeText.textContent = text;
1477
+ }
1478
+ }
1479
+
1480
+ setStatus(text) {
1481
+ this.setVideoStatus(text);
1482
+ }
1483
+
1484
+ setProgress(fraction) {
1485
+ if (this.popupElements?.challengeFill) {
1486
+ this.popupElements.challengeFill.style.width = `${Math.round(fraction * 100)}%`;
1487
+ }
1488
+ }
1489
+
1490
+ setTask(taskId) {
1491
+ if (this.popupElements?.faceGuide) {
1492
+ this.popupElements.faceGuide.setAttribute('data-task', taskId || '');
1493
+ }
1494
+ }
1495
+
1496
+ showActions(label) {
1497
+ if (!this.popupElements?.actions) return;
1498
+ this.popupElements.actions.classList.remove('oa-hidden');
1499
+ if (this.popupElements.startBtn) {
1500
+ this.popupElements.startBtn.textContent = label;
1501
+ }
1502
+ }
1503
+
1504
+ hideActions() {
1505
+ if (this.popupElements?.actions) {
1506
+ this.popupElements.actions.classList.add('oa-hidden');
1507
+ }
1508
+ }
1509
+
1510
+ showResult(outcome, message) {
1511
+ if (outcome === 'pass') {
1512
+ this.closePopup();
1513
+ this.setState('verified');
1514
+ return;
1515
+ }
1516
+
1517
+ if (outcome === 'fail') {
1518
+ if (this.params.size === 'invisible') {
1519
+ if (this.popupElements?.body) {
1520
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
1521
+ }
1522
+ this.hideActions();
1523
+ this.showActions('Try Again');
1524
+ } else {
1525
+ this.closePopup();
1526
+ this.setState('retry');
1527
+ }
1528
+ return;
1529
+ }
1530
+
1531
+ if (outcome === 'retry') {
1532
+ if (this.params.size !== 'invisible') {
1533
+ this.closePopup();
1534
+ this.setState('retry');
1535
+ return;
1536
+ }
1537
+
1538
+ if (this.popupElements?.body) {
1539
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
1540
+ }
1541
+ this.hideActions();
1542
+ this.showActions('Try Again');
1543
+ }
1544
+ }
1545
+
1546
+ showError() {
1547
+ this.setState('retry');
1548
+ }
1549
+
1550
+ clearError() {
1551
+ if (this.elements.errorSlot) {
1552
+ this.elements.errorSlot.innerHTML = '';
1553
+ }
1554
+ }
1555
+
1556
+ closePopup() {
1557
+ if (!this.popup) return;
1558
+ this.popup.cleanup?.();
1559
+ this.popup.themeCleanup?.();
1560
+ this.popup.host.remove();
1561
+ this.popup = null;
1562
+ this.popupElements = null;
1563
+
1564
+ if (this.state === 'loading') {
1565
+ this.setState('idle');
1566
+ }
1567
+ }
1568
+
1569
+ setState(newState) {
1570
+ this.state = newState;
1571
+ const cb = this.elements.checkbox;
1572
+ const box = this.elements.checkBox;
1573
+ if (!cb || !box) return;
1574
+
1575
+ cb.classList.remove('oa-loading', 'oa-verified', 'oa-failed', 'oa-retry', 'oa-expired');
1576
+ cb.setAttribute('aria-checked', 'false');
1577
+ box.innerHTML = '';
1578
+
1579
+ switch (newState) {
1580
+ case 'loading':
1581
+ cb.classList.add('oa-loading');
1582
+ box.innerHTML = `<span class="oa-spinner">` + `${SPINNER_SVG}</span>`;
1583
+ break;
1584
+ case 'verified':
1585
+ cb.classList.add('oa-verified');
1586
+ cb.setAttribute('aria-checked', 'true');
1587
+ box.innerHTML = CHECK_SVG;
1588
+ break;
1589
+ case 'failed':
1590
+ cb.classList.add('oa-failed');
1591
+ box.innerHTML = '✕';
1592
+ break;
1593
+ case 'retry':
1594
+ cb.classList.add('oa-retry');
1595
+ box.innerHTML = RETRY_SVG;
1596
+ break;
1597
+ case 'expired':
1598
+ cb.classList.add('oa-expired');
1599
+ cb.setAttribute('aria-checked', 'false');
1600
+ break;
1601
+ }
1602
+ }
1603
+
1604
+ getToken() {
1605
+ return this.token;
1606
+ }
1607
+
1608
+ reset() {
1609
+ this.token = null;
1610
+ this.closePopup();
1611
+ this.setState('idle');
1612
+ }
1613
+
1614
+ destroy() {
1615
+ this.closePopup();
1616
+ this.themeCleanup?.();
1617
+ this.host?.remove();
1618
+ }
1619
+ }
1620
+
1621
+ function createModalWidget(params) {
1622
+ const widget = new Widget(document.createElement('div'), { ...params, size: 'invisible' });
1623
+ return widget;
1624
+ }
1625
+
1626
+ function resolveContainer(container) {
1627
+ if (typeof container === 'string') {
1628
+ return document.querySelector(container);
1629
+ }
1630
+ return container;
1631
+ }
1632
+
1633
+ function findPopupPosition(anchorRect, popupRect) {
1634
+ const viewport = {
1635
+ width: window.innerWidth,
1636
+ height: window.innerHeight,
1637
+ };
1638
+
1639
+ const popupWidth = Math.max(popupRect.width || 0, POPUP_MIN_WIDTH);
1640
+ const popupHeight = Math.max(popupRect.height || 0, POPUP_MIN_HEIGHT);
1641
+ const availableWidth = viewport.width - POPUP_MARGIN * 2;
1642
+ const availableHeight = viewport.height - POPUP_MARGIN * 2;
1643
+
1644
+ if (popupWidth > availableWidth || popupHeight > availableHeight) {
1645
+ return { mode: 'modal' };
1646
+ }
1647
+
1648
+ const left = clampLeft(
1649
+ anchorRect.left + anchorRect.width / 2 - popupWidth / 2,
1650
+ viewport.width,
1651
+ popupWidth
1652
+ );
1653
+
1654
+ const topBelow = anchorRect.bottom + POPUP_MARGIN;
1655
+ const topAbove = anchorRect.top - popupHeight - POPUP_MARGIN;
1656
+ const fitsBelow = topBelow + popupHeight <= viewport.height - POPUP_MARGIN;
1657
+ const fitsAbove = topAbove >= POPUP_MARGIN;
1658
+
1659
+ if (fitsBelow || !fitsAbove) {
1660
+ return {
1661
+ mode: 'popup',
1662
+ placement: 'below',
1663
+ top: clampTop(topBelow, viewport.height, popupHeight),
1664
+ left,
1665
+ };
1666
+ }
1667
+
1668
+ return {
1669
+ mode: 'popup',
1670
+ placement: 'above',
1671
+ top: clampTop(topAbove, viewport.height, popupHeight),
1672
+ left,
1673
+ };
1674
+ }
1675
+
1676
+ function clampLeft(left, viewportWidth, popupWidth) {
1677
+ return Math.min(Math.max(POPUP_MARGIN, left), viewportWidth - popupWidth - POPUP_MARGIN);
1678
+ }
1679
+
1680
+ function clampTop(top, viewportHeight, popupHeight) {
1681
+ return Math.min(Math.max(POPUP_MARGIN, top), viewportHeight - popupHeight - POPUP_MARGIN);
1682
+ }
1683
+
1684
+ let FaceLandmarker = null;
1685
+ let landmarker = null;
1686
+ let lastTimestampMs = -1;
1687
+ let visionModule = null;
1688
+
1689
+ async function loadVision() {
1690
+ if (visionModule) return visionModule;
1691
+ visionModule = await import(MEDIAPIPE_VISION);
1692
+ FaceLandmarker = visionModule.FaceLandmarker;
1693
+ return visionModule;
1694
+ }
1695
+
1696
+ async function loadModel() {
1697
+ const response = await fetch(MEDIAPIPE_MODEL);
1698
+ if (!response.ok) {
1699
+ throw new Error('Failed to load face landmarker model');
1700
+ }
1701
+ return new Uint8Array(await response.arrayBuffer());
1702
+ }
1703
+
1704
+ async function initTracker(modelBuffer) {
1705
+ const vision = await loadVision();
1706
+ const resolver = await vision.FilesetResolver.forVisionTasks(MEDIAPIPE_WASM);
1707
+
1708
+ if (landmarker) {
1709
+ landmarker.close();
1710
+ }
1711
+ lastTimestampMs = -1;
1712
+
1713
+ landmarker = await FaceLandmarker.createFromOptions(resolver, {
1714
+ baseOptions: {
1715
+ modelAssetBuffer: new Uint8Array(modelBuffer),
1716
+ delegate: 'GPU',
1717
+ },
1718
+ runningMode: 'VIDEO',
1719
+ numFaces: 2,
1720
+ outputFaceBlendshapes: true,
1721
+ outputFacialTransformationMatrixes: true,
1722
+ });
1723
+ }
1724
+
1725
+ function track(video, timestampMs) {
1726
+ if (!landmarker) return null;
1727
+
1728
+ const normalized = normalizeTimestamp(timestampMs);
1729
+ const result = landmarker.detectForVideo(video, normalized);
1730
+ const faceCount = result.faceLandmarks?.length ?? 0;
1731
+
1732
+ if (faceCount === 0) {
1733
+ return { faceCount: 0, timestampMs: normalized };
1734
+ }
1735
+
1736
+ const landmarks = result.faceLandmarks[0];
1737
+
1738
+ return {
1739
+ faceCount,
1740
+ timestampMs: normalized,
1741
+ landmarks,
1742
+ blendshapes: parseBlendshapes(result.faceBlendshapes?.[0]),
1743
+ headPose: extractHeadPose(result.facialTransformationMatrixes?.[0]),
1744
+ boundingBox: computeBoundingBox(landmarks),
1745
+ };
1746
+ }
1747
+
1748
+ function destroyTracker() {
1749
+ if (landmarker) {
1750
+ landmarker.close();
1751
+ landmarker = null;
1752
+ }
1753
+ lastTimestampMs = -1;
1754
+ }
1755
+
1756
+ function normalizeTimestamp(timestampMs) {
1757
+ const safe = Number.isFinite(timestampMs) ? timestampMs : performance.now();
1758
+ const whole = Math.floor(safe);
1759
+ const normalized = Math.max(whole, lastTimestampMs + 1);
1760
+ lastTimestampMs = normalized;
1761
+ return normalized;
1762
+ }
1763
+
1764
+ function parseBlendshapes(blendshapeResult) {
1765
+ if (!blendshapeResult?.categories) return {};
1766
+ const map = {};
1767
+ for (const category of blendshapeResult.categories) {
1768
+ map[category.categoryName] = category.score;
1769
+ }
1770
+ return map;
1771
+ }
1772
+
1773
+ function extractHeadPose(matrix) {
1774
+ if (!matrix?.data || matrix.data.length < 16) {
1775
+ return { yaw: 0, pitch: 0, roll: 0 };
1776
+ }
1777
+ const m = matrix.data;
1778
+ const deg = 180 / Math.PI;
1779
+ return {
1780
+ yaw: Math.atan2(m[8], m[10]) * deg,
1781
+ pitch: Math.asin(-Math.max(-1, Math.min(1, m[9]))) * deg,
1782
+ roll: Math.atan2(m[1], m[5]) * deg,
1783
+ };
1784
+ }
1785
+
1786
+ function computeBoundingBox(landmarks) {
1787
+ let minX = 1,
1788
+ minY = 1,
1789
+ maxX = 0,
1790
+ maxY = 0;
1791
+
1792
+ for (const point of landmarks) {
1793
+ if (point.x < minX) minX = point.x;
1794
+ if (point.y < minY) minY = point.y;
1795
+ if (point.x > maxX) maxX = point.x;
1796
+ if (point.y > maxY) maxY = point.y;
1797
+ }
1798
+
1799
+ const width = maxX - minX;
1800
+ const height = maxY - minY;
1801
+
1802
+ return {
1803
+ x: minX,
1804
+ y: minY,
1805
+ width,
1806
+ height,
1807
+ area: width * height,
1808
+ };
1809
+ }
1810
+
1811
+ function startPositioning(video, callbacks) {
1812
+ let stableFrames = 0;
1813
+ let cancelled = false;
1814
+
1815
+ const check = () => {
1816
+ if (cancelled) return;
1817
+
1818
+ const result = track(video, performance.now());
1819
+
1820
+ if (!result || result.faceCount === 0) {
1821
+ callbacks.onStatus?.('Look at the camera');
1822
+ stableFrames = 0;
1823
+ } else if (result.faceCount > 1) {
1824
+ callbacks.onStatus?.('Only one person please');
1825
+ stableFrames = 0;
1826
+ } else {
1827
+ callbacks.onStatus?.('Hold still…');
1828
+ stableFrames++;
1829
+ }
1830
+
1831
+ if (stableFrames >= STABLE_FRAMES_REQUIRED) {
1832
+ callbacks.onReady?.();
1833
+ return;
1834
+ }
1835
+
1836
+ setTimeout(check, POSITION_CHECK_MS);
1837
+ };
1838
+
1839
+ check();
1840
+
1841
+ return {
1842
+ cancel: () => {
1843
+ cancelled = true;
1844
+ },
1845
+ };
1846
+ }
1847
+
1848
+ let initialized = false;
1849
+ let faceapi = null;
1850
+
1851
+ function loadScript$1(url) {
1852
+ return new Promise((resolve, reject) => {
1853
+ if (typeof window === 'undefined') {
1854
+ return reject(new Error('No DOM available'));
1855
+ }
1856
+
1857
+ const existing = document.querySelector(`script[src="${url}"]`);
1858
+ if (existing) {
1859
+ if (window.faceapi) return resolve(window.faceapi);
1860
+ existing.addEventListener('load', () => resolve(window.faceapi));
1861
+ return;
1862
+ }
1863
+
1864
+ const script = document.createElement('script');
1865
+ script.src = url;
1866
+ script.crossOrigin = 'anonymous';
1867
+ script.onload = () => resolve(window.faceapi);
1868
+ script.onerror = () => reject(new Error(`Failed to load ${url}`));
1869
+ document.head.appendChild(script);
1870
+ });
1871
+ }
1872
+
1873
+ async function initAgeEstimator() {
1874
+ if (initialized) return;
1875
+
1876
+ faceapi = await loadScript$1(FACEAPI_CDN);
1877
+
1878
+ await Promise.all([
1879
+ faceapi.nets.tinyFaceDetector.loadFromUri(FACEAPI_MODEL_CDN),
1880
+ faceapi.nets.faceLandmark68TinyNet.loadFromUri(FACEAPI_MODEL_CDN),
1881
+ faceapi.nets.ageGenderNet.loadFromUri(FACEAPI_MODEL_CDN),
1882
+ ]);
1883
+
1884
+ initialized = true;
1885
+ }
1886
+
1887
+ async function estimateAge(canvas) {
1888
+ if (!faceapi) {
1889
+ throw new Error('Age estimator not initialized');
1890
+ }
1891
+
1892
+ const detection = await faceapi
1893
+ .detectSingleFace(
1894
+ canvas,
1895
+ new faceapi.TinyFaceDetectorOptions({
1896
+ inputSize: 224,
1897
+ })
1898
+ )
1899
+ .withFaceLandmarks(true)
1900
+ .withAgeAndGender();
1901
+
1902
+ if (!detection) return null;
1903
+
1904
+ return {
1905
+ age: detection.age,
1906
+ gender: detection.gender,
1907
+ confidence: detection.detection.score,
1908
+ };
1909
+ }
1910
+
1911
+ async function estimateAgeBurst(frames) {
1912
+ const results = [];
1913
+ for (const frame of frames) {
1914
+ const result = await estimateAge(frame);
1915
+ if (result) results.push(result);
1916
+ }
1917
+ return results;
1918
+ }
1919
+
1920
+ const TASKS = [
1921
+ {
1922
+ id: 'turn-left',
1923
+ instruction: 'Turn your head to the left',
1924
+ check: (h) => detectYawShift(h, 20),
1925
+ },
1926
+ {
1927
+ id: 'turn-right',
1928
+ instruction: 'Turn your head to the right',
1929
+ check: (h) => detectYawShift(h, -20),
1930
+ },
1931
+ {
1932
+ id: 'nod',
1933
+ instruction: 'Nod your head down then up',
1934
+ check: (h) => detectNod(h),
1935
+ },
1936
+ {
1937
+ id: 'blink-twice',
1938
+ instruction: 'Blink twice',
1939
+ check: (h) => detectDoubleBlink(h),
1940
+ },
1941
+ {
1942
+ id: 'move-closer',
1943
+ instruction: 'Move closer then back',
1944
+ check: (h) => detectDistanceChange(h),
1945
+ },
1946
+ ];
1947
+
1948
+ function pickTasks(count = TASK_COUNT) {
1949
+ const shuffled = [...TASKS].sort(() => Math.random() - 0.5);
1950
+ return shuffled.slice(0, count);
1951
+ }
1952
+
1953
+ function createSession(tasks) {
1954
+ return {
1955
+ tasks: pickTasks(),
1956
+ currentIndex: 0,
1957
+ history: [],
1958
+ taskStartTime: 0,
1959
+ completedTasks: 0,
1960
+ requiredPasses: REQUIRED_TASK_PASSES,
1961
+ failed: false,
1962
+ failReason: null,
1963
+ };
1964
+ }
1965
+
1966
+ function processFrame(session, trackingResult) {
1967
+ if (session.failed || isComplete(session)) return;
1968
+ if (session.currentIndex >= session.tasks.length) return;
1969
+ if (!trackingResult || trackingResult.faceCount === 0) return;
1970
+
1971
+ if (trackingResult.faceCount > 1) {
1972
+ session.failed = true;
1973
+ session.failReason = null;
1974
+ return;
1975
+ }
1976
+
1977
+ const entry = {
1978
+ timestamp: trackingResult.timestampMs,
1979
+ headPose: trackingResult.headPose,
1980
+ blendshapes: trackingResult.blendshapes,
1981
+ boundingBox: trackingResult.boundingBox,
1982
+ };
1983
+
1984
+ if (session.history.length === 0) {
1985
+ session.taskStartTime = trackingResult.timestampMs;
1986
+ }
1987
+
1988
+ session.history.push(entry);
1989
+
1990
+ const elapsed = trackingResult.timestampMs - session.taskStartTime;
1991
+
1992
+ if (elapsed > TASK_TIMEOUT_MS) {
1993
+ advanceTask(session);
1994
+ return;
1995
+ }
1996
+
1997
+ if (elapsed < MIN_TASK_TIME_MS) return;
1998
+
1999
+ const task = session.tasks[session.currentIndex];
2000
+ if (!task.check(session.history)) return;
2001
+
2002
+ if (isSuspicious(session.history)) {
2003
+ session.failed = true;
2004
+ session.failReason = null;
2005
+ return;
2006
+ }
2007
+
2008
+ session.completedTasks++;
2009
+ advanceTask(session);
2010
+ }
2011
+
2012
+ function isComplete(session) {
2013
+ return session.currentIndex >= session.tasks.length;
2014
+ }
2015
+
2016
+ function isPassed(session) {
2017
+ return session.completedTasks >= session.requiredPasses;
2018
+ }
2019
+
2020
+ function currentInstruction(session) {
2021
+ if (session.currentIndex >= session.tasks.length) {
2022
+ return null;
2023
+ }
2024
+ return session.tasks[session.currentIndex].instruction;
2025
+ }
2026
+
2027
+ function currentTaskId(session) {
2028
+ if (session.currentIndex >= session.tasks.length) {
2029
+ return null;
2030
+ }
2031
+ return session.tasks[session.currentIndex].id;
2032
+ }
2033
+
2034
+ function progress(session) {
2035
+ if (session.tasks.length === 0) return 1;
2036
+ return Math.min(session.currentIndex / session.tasks.length, 1);
2037
+ }
2038
+
2039
+ function advanceTask(session) {
2040
+ session.currentIndex++;
2041
+ session.history = [];
2042
+ session.taskStartTime = 0;
2043
+
2044
+ const remaining = session.tasks.length - session.currentIndex;
2045
+ const canStillPass = session.completedTasks + remaining >= session.requiredPasses;
2046
+
2047
+ if (!canStillPass) {
2048
+ session.failed = true;
2049
+ session.failReason = null;
2050
+ }
2051
+ }
2052
+
2053
+ function detectYawShift(history, targetDelta) {
2054
+ if (history.length < 5) return false;
2055
+ const baseYaw = history[0].headPose.yaw;
2056
+ const direction = Math.sign(targetDelta);
2057
+ const threshold = Math.abs(targetDelta);
2058
+
2059
+ return history.some((entry) => {
2060
+ const delta = (entry.headPose.yaw - baseYaw) * direction;
2061
+ return delta > threshold;
2062
+ });
2063
+ }
2064
+
2065
+ function detectNod(history) {
2066
+ if (history.length < 10) return false;
2067
+ const basePitch = history[0].headPose.pitch;
2068
+ let wentDown = false;
2069
+ let cameBack = false;
2070
+
2071
+ for (const entry of history) {
2072
+ const delta = entry.headPose.pitch - basePitch;
2073
+ if (delta > 15) wentDown = true;
2074
+ if (wentDown && Math.abs(delta) < 8) cameBack = true;
2075
+ }
2076
+
2077
+ return wentDown && cameBack;
2078
+ }
2079
+
2080
+ function detectDoubleBlink(history) {
2081
+ if (history.length < 10) return false;
2082
+ let blinkCount = 0;
2083
+ let eyesClosed = false;
2084
+
2085
+ for (const entry of history) {
2086
+ const left = entry.blendshapes.eyeBlinkLeft ?? 0;
2087
+ const right = entry.blendshapes.eyeBlinkRight ?? 0;
2088
+ const bothClosed = left > 0.6 && right > 0.6;
2089
+
2090
+ if (bothClosed && !eyesClosed) {
2091
+ blinkCount++;
2092
+ eyesClosed = true;
2093
+ } else if (!bothClosed) {
2094
+ eyesClosed = false;
2095
+ }
2096
+ }
2097
+
2098
+ return blinkCount >= 2;
2099
+ }
2100
+
2101
+ function detectDistanceChange(history) {
2102
+ if (history.length < 10) return false;
2103
+ const baseArea = history[0].boundingBox.area;
2104
+ let wentCloser = false;
2105
+ let cameBack = false;
2106
+
2107
+ for (const entry of history) {
2108
+ const ratio = entry.boundingBox.area / baseArea;
2109
+ if (ratio > 1.3) wentCloser = true;
2110
+ if (wentCloser && ratio < 1.15) cameBack = true;
2111
+ }
2112
+
2113
+ return wentCloser && cameBack;
2114
+ }
2115
+
2116
+ function isSuspicious(history) {
2117
+ if (history.length < 5) return false;
2118
+
2119
+ const deltas = [];
2120
+ for (let i = 1; i < history.length; i++) {
2121
+ const dy = Math.abs(history[i].headPose.yaw - history[i - 1].headPose.yaw);
2122
+ const dp = Math.abs(history[i].headPose.pitch - history[i - 1].headPose.pitch);
2123
+ deltas.push(dy + dp);
2124
+ }
2125
+
2126
+ if (deltas.every((d) => d < 0.1)) return true;
2127
+
2128
+ const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
2129
+ const variance = deltas.reduce((s, d) => s + (d - mean) ** 2, 0) / deltas.length;
2130
+
2131
+ return variance < 0.01 && mean > 0.5;
2132
+ }
2133
+
2134
+ function base64UrlEncode(data) {
2135
+ const text = typeof data === 'string' ? data : String.fromCharCode(...new Uint8Array(data));
2136
+ return btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
2137
+ }
2138
+
2139
+ function base64UrlDecode(str) {
2140
+ const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
2141
+ const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
2142
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
2143
+ }
2144
+
2145
+ async function getSigningKey() {
2146
+ const raw = crypto.getRandomValues(new Uint8Array(32));
2147
+ return crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, [
2148
+ 'sign',
2149
+ 'verify',
2150
+ ]);
2151
+ }
2152
+
2153
+ let cachedKey = null;
2154
+
2155
+ async function ensureKey() {
2156
+ if (!cachedKey) cachedKey = await getSigningKey();
2157
+ return cachedKey;
2158
+ }
2159
+
2160
+ async function createToken(payload) {
2161
+ const key = await ensureKey();
2162
+
2163
+ const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
2164
+
2165
+ const body = base64UrlEncode(
2166
+ JSON.stringify({
2167
+ ...payload,
2168
+ iat: Math.floor(Date.now() / 1000),
2169
+ exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_S,
2170
+ })
2171
+ );
2172
+
2173
+ const data = new TextEncoder().encode(`${header}.${body}`);
2174
+ const signature = await crypto.subtle.sign('HMAC', key, data);
2175
+
2176
+ return `${header}.${body}.${base64UrlEncode(signature)}`;
2177
+ }
2178
+
2179
+ async function verifyToken(token) {
2180
+ const key = await ensureKey();
2181
+ const [header, body, sig] = token.split('.');
2182
+
2183
+ if (!header || !body || !sig) return null;
2184
+
2185
+ let signature;
2186
+ let data;
2187
+ try {
2188
+ data = new TextEncoder().encode(`${header}.${body}`);
2189
+ signature = base64UrlDecode(sig);
2190
+ } catch {
2191
+ return null;
2192
+ }
2193
+
2194
+ const valid = await crypto.subtle.verify('HMAC', key, signature, data);
2195
+
2196
+ if (!valid) return null;
2197
+
2198
+ const decoded = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
2199
+
2200
+ if (decoded.exp && decoded.exp < Date.now() / 1000) {
2201
+ return null;
2202
+ }
2203
+
2204
+ return decoded;
2205
+ }
2206
+
2207
+ function decodeToken(token) {
2208
+ const [, body] = token.split('.');
2209
+ if (!body) return null;
2210
+ return JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
2211
+ }
2212
+
2213
+ function createTransport(mode, options = {}) {
2214
+ if (mode === 'serverless') {
2215
+ return createServerlessTransport();
2216
+ }
2217
+
2218
+ const baseUrl = mode === 'custom' ? options.server : 'https://api.openage.dev';
2219
+
2220
+ return createServerTransport(baseUrl, options);
2221
+ }
2222
+
2223
+ function createServerlessTransport(options) {
2224
+ return {
2225
+ async verify(payload) {
2226
+ const { estimatedAge, livenessOk } = payload;
2227
+
2228
+ if (!livenessOk) {
2229
+ return { success: false, token: null };
2230
+ }
2231
+
2232
+ const token = await createToken({
2233
+ estimatedAge,
2234
+ livenessOk: true,
2235
+ mode: 'serverless',
2236
+ });
2237
+
2238
+ return { success: true, token };
2239
+ },
2240
+ close() {},
2241
+ };
2242
+ }
2243
+
2244
+ function createServerTransport(baseUrl, options) {
2245
+ let session = null;
2246
+ let channel = null;
2247
+
2248
+ return {
2249
+ async createSession() {
2250
+ const transports = [];
2251
+ if (typeof WebSocket !== 'undefined') {
2252
+ transports.push('websocket');
2253
+ }
2254
+ transports.push('poll');
2255
+
2256
+ const response = await fetch(`${baseUrl}/api/session`, {
2257
+ method: 'POST',
2258
+ headers: {
2259
+ 'Content-Type': 'application/json',
2260
+ },
2261
+ body: JSON.stringify({
2262
+ sitekey: options.sitekey,
2263
+ action: options.action,
2264
+ supportedTransports: transports,
2265
+ }),
2266
+ });
2267
+
2268
+ if (!response.ok) {
2269
+ throw new Error('Request failed');
2270
+ }
2271
+
2272
+ session = await response.json();
2273
+ return session;
2274
+ },
2275
+
2276
+ openChannel() {
2277
+ if (!session) {
2278
+ throw new Error('No session');
2279
+ }
2280
+
2281
+ if (session.transport === 'websocket') {
2282
+ channel = createWsChannel(baseUrl, session.sessionId);
2283
+ } else {
2284
+ channel = createPollChannel(baseUrl, session.sessionId);
2285
+ }
2286
+ },
2287
+
2288
+ async receive() {
2289
+ if (!channel) return null;
2290
+ return channel.receive();
2291
+ },
2292
+
2293
+ async send(data) {
2294
+ if (!channel) return;
2295
+ return channel.send(data);
2296
+ },
2297
+
2298
+ async sendAndReceive(data) {
2299
+ if (!channel) return null;
2300
+ return channel.sendAndReceive(data);
2301
+ },
2302
+
2303
+ async verify(payload) {
2304
+ if (channel) {
2305
+ return this.verifyViaChannel(payload);
2306
+ }
2307
+
2308
+ const response = await fetch(`${baseUrl}/verify`, {
2309
+ method: 'POST',
2310
+ headers: {
2311
+ 'Content-Type': 'application/json',
2312
+ },
2313
+ body: JSON.stringify({
2314
+ response: payload.token,
2315
+ sitekey: options.sitekey,
2316
+ action: options.action,
2317
+ }),
2318
+ });
2319
+
2320
+ if (!response.ok) {
2321
+ return {
2322
+ success: false,
2323
+ token: null,
2324
+ };
2325
+ }
2326
+
2327
+ return response.json();
2328
+ },
2329
+
2330
+ async verifyViaChannel(payload) {
2331
+ await channel.send(payload);
2332
+ return channel.receive();
2333
+ },
2334
+
2335
+ getSession() {
2336
+ return session;
2337
+ },
2338
+
2339
+ close() {
2340
+ channel?.close();
2341
+ channel = null;
2342
+ session = null;
2343
+ },
2344
+ };
2345
+ }
2346
+
2347
+ function createWsChannel(baseUrl, sessionId) {
2348
+ const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/$/, '');
2349
+ const url = `${wsUrl}/api/ws/${sessionId}`;
2350
+ const ws = new WebSocket(url);
2351
+ let pending = [];
2352
+ let closed = false;
2353
+
2354
+ const waitReady = new Promise((resolve, reject) => {
2355
+ ws.onopen = () => {
2356
+ resolve();
2357
+ };
2358
+ ws.onerror = () => {
2359
+ reject(new Error('Connection failed'));
2360
+ };
2361
+ });
2362
+
2363
+ ws.onmessage = (event) => {
2364
+ const message = JSON.parse(event.data);
2365
+ const resolver = pending.shift();
2366
+ if (resolver) resolver(message);
2367
+ };
2368
+
2369
+ ws.onclose = () => {
2370
+ closed = true;
2371
+ for (const resolver of pending) resolver(null);
2372
+ pending = [];
2373
+ };
2374
+
2375
+ return {
2376
+ receive() {
2377
+ if (closed) return Promise.resolve(null);
2378
+ return new Promise((resolve) => {
2379
+ pending.push(resolve);
2380
+ });
2381
+ },
2382
+ async send(data) {
2383
+ await waitReady;
2384
+ ws.send(JSON.stringify(data));
2385
+ },
2386
+ async sendAndReceive(data) {
2387
+ await waitReady;
2388
+ ws.send(JSON.stringify(data));
2389
+ return this.receive();
2390
+ },
2391
+ close() {
2392
+ closed = true;
2393
+ ws.close();
2394
+ },
2395
+ };
2396
+ }
2397
+
2398
+ function createPollChannel(baseUrl, sessionId) {
2399
+ return {
2400
+ async receive() {
2401
+ const response = await fetch(`${baseUrl}/api/poll/${sessionId}`);
2402
+ if (!response.ok) {
2403
+ throw new Error('Request failed');
2404
+ }
2405
+ return response.json();
2406
+ },
2407
+ async send(data) {
2408
+ const response = await fetch(`${baseUrl}/api/verify/${sessionId}`, {
2409
+ method: 'POST',
2410
+ headers: {
2411
+ 'Content-Type': 'application/json',
2412
+ },
2413
+ body: JSON.stringify(data),
2414
+ });
2415
+ if (!response.ok) {
2416
+ throw new Error('Request failed');
2417
+ }
2418
+ },
2419
+ async sendAndReceive(data) {
2420
+ const response = await fetch(`${baseUrl}/api/verify/${sessionId}`, {
2421
+ method: 'POST',
2422
+ headers: {
2423
+ 'Content-Type': 'application/json',
2424
+ },
2425
+ body: JSON.stringify(data),
2426
+ });
2427
+ if (!response.ok) {
2428
+ throw new Error('Request failed');
2429
+ }
2430
+ return response.json();
2431
+ },
2432
+ close() {},
2433
+ };
2434
+ }
2435
+
2436
+ let wasmModule = null;
2437
+ let vmSession = null;
2438
+ let challengeBundle = null;
2439
+ let _faceData = null;
2440
+ let _challengeParams = null;
2441
+ let _bridge = null;
2442
+
2443
+ function loadScript(url) {
2444
+ return new Promise((resolve, reject) => {
2445
+ const existing = document.querySelector(`script[src="${url}"]`);
2446
+ if (existing) return resolve();
2447
+
2448
+ const script = document.createElement('script');
2449
+ script.src = url;
2450
+ script.onload = resolve;
2451
+ script.onerror = () => {
2452
+ reject(new Error(`Failed to load ${url}`));
2453
+ };
2454
+ document.head.appendChild(script);
2455
+ });
2456
+ }
2457
+
2458
+ async function initVM(session) {
2459
+ vmSession = session;
2460
+
2461
+ await loadScript(session.wasmJs);
2462
+
2463
+ const loaderModule = await import(session.loaderJs);
2464
+ wasmModule = await loaderModule.initModule(session.wasmBin);
2465
+
2466
+ const initFn = session.exports.vm_init;
2467
+ const result = wasmModule[`_${initFn}`]();
2468
+ if (result !== 0) throw new Error('VM init failed');
2469
+
2470
+ const bundleResponse = await fetch(session.challengeVmbc);
2471
+ challengeBundle = new Uint8Array(await bundleResponse.arrayBuffer());
2472
+ }
2473
+
2474
+ async function decryptModel(session, modelId) {
2475
+ if (!wasmModule || !vmSession) {
2476
+ throw new Error('VM not loaded');
2477
+ }
2478
+
2479
+ const modelInfo = session.models[modelId];
2480
+ if (!modelInfo) {
2481
+ throw new Error(`Unknown model: ${modelId}`);
2482
+ }
2483
+
2484
+ const response = await fetch(modelInfo.url);
2485
+ if (!response.ok) {
2486
+ throw new Error(`Failed to fetch model: ${modelId}`);
2487
+ }
2488
+
2489
+ const encrypted = new Uint8Array(await response.arrayBuffer());
2490
+
2491
+ const decryptFn = session.exports.vm_decrypt_blob;
2492
+ const freeFn = session.exports.vm_free;
2493
+ const length = encrypted.length;
2494
+
2495
+ const inputPtr = wasmModule._malloc(length);
2496
+ wasmModule.HEAPU8.set(encrypted, inputPtr);
2497
+
2498
+ const outLenPtr = wasmModule._malloc(4);
2499
+ const outPtr = wasmModule[`_${decryptFn}`](inputPtr, length, outLenPtr);
2500
+ wasmModule._free(inputPtr);
2501
+
2502
+ if (!outPtr) {
2503
+ wasmModule._free(outLenPtr);
2504
+ throw new Error(`Decryption failed: ${modelId}`);
2505
+ }
2506
+
2507
+ const outLen = readU32(wasmModule, outLenPtr);
2508
+ wasmModule._free(outLenPtr);
2509
+
2510
+ const result = new Uint8Array(outLen);
2511
+ result.set(wasmModule.HEAPU8.subarray(outPtr, outPtr + outLen));
2512
+ wasmModule[`_${freeFn}`](outPtr);
2513
+ return result.buffer;
2514
+ }
2515
+
2516
+ function readU32(mod, ptr) {
2517
+ return (
2518
+ mod.HEAPU8[ptr] |
2519
+ (mod.HEAPU8[ptr + 1] << 8) |
2520
+ (mod.HEAPU8[ptr + 2] << 16) |
2521
+ (mod.HEAPU8[ptr + 3] << 24)
2522
+ );
2523
+ }
2524
+
2525
+ function defineVmGlobal(name, getter) {
2526
+ try {
2527
+ delete window[name];
2528
+ } catch (_) {}
2529
+ Object.defineProperty(window, name, {
2530
+ get: getter,
2531
+ set() {},
2532
+ configurable: true,
2533
+ });
2534
+ }
2535
+
2536
+ function setFaceData(faceData) {
2537
+ _faceData = faceData;
2538
+ defineVmGlobal('__vmFaceData', () => _faceData);
2539
+ }
2540
+
2541
+ function setChallengeParams(params) {
2542
+ _challengeParams = params;
2543
+ defineVmGlobal('__vmChallenge', () => _challengeParams);
2544
+ }
2545
+
2546
+ function registerBridge(bridge) {
2547
+ _bridge = Object.freeze({ ...bridge });
2548
+ defineVmGlobal('__vmBridge', () => _bridge);
2549
+ }
2550
+
2551
+ function unregisterBridge() {
2552
+ _bridge = null;
2553
+ try {
2554
+ delete window.__vmBridge;
2555
+ } catch (_) {}
2556
+ }
2557
+
2558
+ function executeChallenge() {
2559
+ if (!wasmModule || !challengeBundle) {
2560
+ throw new Error('VM not loaded');
2561
+ }
2562
+
2563
+ const execFn = vmSession.exports.vm_exec_bytecode;
2564
+ const freeFn = vmSession.exports.vm_free;
2565
+ const length = challengeBundle.length;
2566
+
2567
+ const inputPtr = wasmModule._malloc(length);
2568
+ wasmModule.HEAPU8.set(challengeBundle, inputPtr);
2569
+
2570
+ const outLenPtr = wasmModule._malloc(4);
2571
+ const outPtr = wasmModule[`_${execFn}`](inputPtr, length, outLenPtr);
2572
+ wasmModule._free(inputPtr);
2573
+
2574
+ if (!outPtr) {
2575
+ wasmModule._free(outLenPtr);
2576
+ const errFn = vmSession.exports.vm_last_error;
2577
+ let message = 'VM execution failed';
2578
+ if (errFn) {
2579
+ const errPtr = wasmModule[`_${errFn}`]();
2580
+ if (errPtr) {
2581
+ const detail = wasmModule.UTF8ToString(errPtr);
2582
+ if (detail) message = detail;
2583
+ }
2584
+ }
2585
+ throw new Error(message);
2586
+ }
2587
+
2588
+ const outLen = readU32(wasmModule, outLenPtr);
2589
+ wasmModule._free(outLenPtr);
2590
+
2591
+ const result = new Uint8Array(outLen);
2592
+ result.set(wasmModule.HEAPU8.subarray(outPtr, outPtr + outLen));
2593
+ wasmModule[`_${freeFn}`](outPtr);
2594
+ return result;
2595
+ }
2596
+
2597
+ function toBase64(bytes) {
2598
+ let binary = '';
2599
+ for (let i = 0; i < bytes.length; i++) {
2600
+ binary += String.fromCharCode(bytes[i]);
2601
+ }
2602
+ return btoa(binary);
2603
+ }
2604
+
2605
+ function destroyVM() {
2606
+ if (!wasmModule || !vmSession) return;
2607
+
2608
+ const destroyFn = vmSession.exports.vm_destroy;
2609
+ if (destroyFn) {
2610
+ wasmModule[`_${destroyFn}`]();
2611
+ }
2612
+
2613
+ wasmModule = null;
2614
+ vmSession = null;
2615
+ challengeBundle = null;
2616
+ _faceData = null;
2617
+ _challengeParams = null;
2618
+ _bridge = null;
2619
+
2620
+ for (const name of ['__vmFaceData', '__vmChallenge', '__vmBridge']) {
2621
+ try {
2622
+ delete window[name];
2623
+ } catch (_) {}
2624
+ }
2625
+ }
2626
+
2627
+ const decryptedCache = new Map();
2628
+
2629
+ async function decryptAndCache(session, modelId) {
2630
+ if (decryptedCache.has(modelId)) {
2631
+ return decryptedCache.get(modelId);
2632
+ }
2633
+
2634
+ const buffer = await decryptModel(session, modelId);
2635
+ decryptedCache.set(modelId, buffer);
2636
+ return buffer;
2637
+ }
2638
+
2639
+ async function ensureModels(session, onProgress) {
2640
+ await decryptAndCache(session, 'mediapipe');
2641
+ }
2642
+
2643
+ async function getMediaPipeModelBuffer(session) {
2644
+ return decryptAndCache(session, 'mediapipe');
2645
+ }
2646
+
2647
+ function clearModelCache() {
2648
+ decryptedCache.clear();
2649
+ }
2650
+
2651
+ let stream = null;
2652
+ let videoElement = null;
2653
+
2654
+ const CAMERA_CONSTRAINTS = {
2655
+ video: {
2656
+ facingMode: 'user',
2657
+ width: { ideal: 640 },
2658
+ height: { ideal: 480 },
2659
+ },
2660
+ };
2661
+
2662
+ function startCamera(video) {
2663
+ return navigator.mediaDevices.getUserMedia(CAMERA_CONSTRAINTS).then((s) => {
2664
+ stream = s;
2665
+ video.srcObject = stream;
2666
+ videoElement = video;
2667
+
2668
+ return new Promise((resolve) => {
2669
+ video.onloadedmetadata = () => {
2670
+ video.play();
2671
+ resolve({
2672
+ width: video.videoWidth,
2673
+ height: video.videoHeight,
2674
+ });
2675
+ };
2676
+ });
2677
+ });
2678
+ }
2679
+
2680
+ function captureFrame() {
2681
+ if (!videoElement) return null;
2682
+
2683
+ const canvas = document.createElement('canvas');
2684
+ canvas.width = videoElement.videoWidth;
2685
+ canvas.height = videoElement.videoHeight;
2686
+
2687
+ const context = canvas.getContext('2d');
2688
+ context.drawImage(videoElement, 0, 0);
2689
+ return canvas;
2690
+ }
2691
+
2692
+ function stopCamera() {
2693
+ if (stream) {
2694
+ stream.getTracks().forEach((track) => track.stop());
2695
+ stream = null;
2696
+ }
2697
+ if (videoElement) {
2698
+ videoElement.srcObject = null;
2699
+ videoElement = null;
2700
+ }
2701
+ }
2702
+
2703
+ function computeAge(ageResults) {
2704
+ if (!ageResults || ageResults.length === 0) return null;
2705
+
2706
+ const ages = ageResults.map((r) => r.age).sort((a, b) => a - b);
2707
+
2708
+ const trimmed = ages.length >= 3 ? ages.slice(1, -1) : ages;
2709
+
2710
+ return trimmed.reduce((s, a) => s + a, 0) / trimmed.length;
2711
+ }
2712
+
2713
+ const TASK_LABELS = {
2714
+ 'turn-left': 'Turn your head left',
2715
+ 'turn-right': 'Turn your head right',
2716
+ nod: 'Nod your head',
2717
+ 'blink-twice': 'Blink twice',
2718
+ 'move-closer': 'Move closer then back',
2719
+ };
2720
+
2721
+ function sleep(ms) {
2722
+ return new Promise((r) => setTimeout(r, ms));
2723
+ }
2724
+
2725
+ async function runChallenge(widget, emitter) {
2726
+ const mode = widget.params.mode || 'serverless';
2727
+
2728
+ if (mode !== 'serverless' && !widget.params.sitekey) {
2729
+ const err = new Error('Configuration error');
2730
+ widget.showResult?.('fail', 'Verification failed');
2731
+ emitter.emit('error', err, widget.id);
2732
+ widget.params.errorCallback?.(err);
2733
+ return;
2734
+ }
2735
+
2736
+ if (mode === 'serverless') {
2737
+ return runServerless(widget, emitter);
2738
+ }
2739
+ return runServer(widget, emitter);
2740
+ }
2741
+
2742
+ async function runServerless(widget, emitter) {
2743
+ const params = widget.params;
2744
+ let retryCount = 0;
2745
+ let modelBuffer = null;
2746
+
2747
+ try {
2748
+ widget.openPopup();
2749
+ widget.setHeroStatus('Loading…');
2750
+
2751
+ await Promise.all([loadVision(), initAgeEstimator()]);
2752
+
2753
+ modelBuffer = await loadModel();
2754
+ widget.showReady();
2755
+
2756
+ await waitForStart(widget);
2757
+ await startCameraFlow(widget, modelBuffer);
2758
+
2759
+ const transport = createTransport('serverless', params);
2760
+
2761
+ const attempt = async () => {
2762
+ widget.showLiveness();
2763
+ widget.setInstruction('');
2764
+ widget.setVideoStatus('Verifying…');
2765
+
2766
+ const session = createSession();
2767
+ await runLivenessLoop(widget.popupElements.video, session, widget);
2768
+
2769
+ if (session.failed || !isPassed(session)) {
2770
+ return { outcome: 'retry' };
2771
+ }
2772
+
2773
+ widget.setInstruction('Hold still…');
2774
+ widget.setVideoStatus('Processing…');
2775
+ const frames = await captureFrameBurst(BURST_FRAMES, BURST_INTERVAL_MS);
2776
+
2777
+ const ageResults = await estimateAgeBurst(frames);
2778
+ const estimatedAge = computeAge(ageResults);
2779
+
2780
+ const result = await transport.verify({
2781
+ estimatedAge,
2782
+ livenessOk: true,
2783
+ });
2784
+
2785
+ return {
2786
+ outcome: result.token ? 'pass' : 'fail',
2787
+ token: result.token,
2788
+ };
2789
+ };
2790
+
2791
+ let result = await attempt();
2792
+
2793
+ while (result.outcome === 'retry' && retryCount < MAX_RETRIES) {
2794
+ retryCount++;
2795
+ widget.showResult('retry', 'Please try again');
2796
+ await waitForStart(widget);
2797
+ await startCameraFlow(widget, modelBuffer);
2798
+ result = await attempt();
2799
+ }
2800
+
2801
+ cleanupLocal();
2802
+ emitResult(widget, emitter, result);
2803
+ } catch (error) {
2804
+ cleanupLocal();
2805
+ widget.showResult('fail', 'Verification failed');
2806
+ emitter.emit('error', error, widget.id);
2807
+ params.errorCallback?.(error);
2808
+ }
2809
+ }
2810
+
2811
+ async function runServer(widget, emitter) {
2812
+ const params = widget.params;
2813
+
2814
+ try {
2815
+ widget.openPopup();
2816
+ widget.setHeroStatus('Connecting…');
2817
+
2818
+ const transport = createTransport(params.mode, params);
2819
+ const session = await transport.createSession();
2820
+
2821
+ widget.setHeroStatus('Loading…');
2822
+ await initVM(session);
2823
+
2824
+ widget.setHeroStatus('Preparing…');
2825
+ await ensureModels(session, () => {});
2826
+
2827
+ await loadVision();
2828
+ const buf = await getMediaPipeModelBuffer(session);
2829
+ await initTracker(buf);
2830
+
2831
+ registerBridge({
2832
+ trackFace: () => {
2833
+ const video = widget.popupElements?.video;
2834
+ if (!video) return 'null';
2835
+ const r = track(video, performance.now());
2836
+ if (!r) return 'null';
2837
+ return JSON.stringify({
2838
+ ts: r.timestampMs ?? performance.now(),
2839
+ faceCount: r.faceCount,
2840
+ headPose: r.headPose || null,
2841
+ blendshapes: r.blendshapes || null,
2842
+ boundingBox: r.boundingBox || null,
2843
+ });
2844
+ },
2845
+ captureFrame: () => (captureFrame() ? 'true' : 'null'),
2846
+ });
2847
+
2848
+ widget.showReady();
2849
+ await waitForStart(widget);
2850
+
2851
+ const video = widget.showCamera();
2852
+ widget.setVideoStatus('Requesting camera…');
2853
+ await startCamera(video);
2854
+ exposeMirrorVideo(video);
2855
+
2856
+ widget.setVideoStatus('Position your face');
2857
+ await waitForPositioning(video, widget);
2858
+
2859
+ widget.showLiveness();
2860
+ transport.openChannel();
2861
+
2862
+ let challenge = await transport.receive();
2863
+ const rounds = session.rounds;
2864
+
2865
+ for (let i = 0; i < rounds; i++) {
2866
+ if (!challenge) {
2867
+ cleanupVM(transport);
2868
+ widget.showResult('fail', 'Verification failed');
2869
+ emitter.emit('error', 'failed', widget.id);
2870
+ return;
2871
+ }
2872
+
2873
+ if (challenge.type === 'verdict') {
2874
+ cleanupVM(transport);
2875
+ emitVerdict(widget, emitter, challenge);
2876
+ return;
2877
+ }
2878
+
2879
+ if (challenge.type === 'timeout') {
2880
+ cleanupVM(transport);
2881
+ widget.showResult('fail', 'Verification failed');
2882
+ emitter.emit('error', 'failed', widget.id);
2883
+ return;
2884
+ }
2885
+
2886
+ widget.setVideoStatus(`Step ${i + 1} of ${rounds}`);
2887
+
2888
+ const task = challenge.token?.task;
2889
+ widget.setInstruction(TASK_LABELS[task] ?? 'Look at the camera');
2890
+ widget.setTask(task);
2891
+ widget.setProgress(i / rounds);
2892
+
2893
+ const faceData = await captureMotion(widget);
2894
+ setFaceData(faceData);
2895
+ setChallengeParams(challenge.token);
2896
+
2897
+ let vmOut;
2898
+ try {
2899
+ vmOut = executeChallenge();
2900
+ } catch {
2901
+ cleanupVM(transport);
2902
+ widget.showResult('fail', 'Verification failed');
2903
+ emitter.emit('error', 'failed', widget.id);
2904
+ return;
2905
+ }
2906
+
2907
+ const payload = {
2908
+ token: challenge.token,
2909
+ tokenSignature: challenge.tokenSignature,
2910
+ response: toBase64(vmOut),
2911
+ };
2912
+
2913
+ const result = await transport.sendAndReceive(payload);
2914
+
2915
+ if (!result) {
2916
+ cleanupVM(transport);
2917
+ widget.showResult('fail', 'Verification failed');
2918
+ emitter.emit('error', 'failed', widget.id);
2919
+ return;
2920
+ }
2921
+
2922
+ if (result.complete) {
2923
+ cleanupVM(transport);
2924
+ emitVerdict(widget, emitter, result);
2925
+ return;
2926
+ }
2927
+
2928
+ if (result.hint) {
2929
+ widget.setVideoStatus(result.hint);
2930
+ await sleep(1000);
2931
+ }
2932
+
2933
+ challenge = result.nextChallenge || null;
2934
+ }
2935
+
2936
+ cleanupVM(transport);
2937
+ } catch (error) {
2938
+ cleanupVM();
2939
+ widget.showResult('fail', 'Verification failed');
2940
+ emitter.emit('error', error, widget.id);
2941
+ params.errorCallback?.(error);
2942
+ }
2943
+ }
2944
+
2945
+ function emitVerdict(widget, emitter, response) {
2946
+ const verdict = response?.verdict || response;
2947
+ const token = verdict?.token || null;
2948
+ const params = widget.params;
2949
+
2950
+ if (token) {
2951
+ widget.token = token;
2952
+ widget.showResult('pass', 'Verified');
2953
+ emitter.emit('verified', token, widget.id);
2954
+ params.callback?.(token);
2955
+ return;
2956
+ }
2957
+
2958
+ widget.showResult('fail', 'Verification failed');
2959
+ emitter.emit('error', 'failed', widget.id);
2960
+ params.errorCallback?.('failed');
2961
+ }
2962
+
2963
+ async function captureMotion(widget) {
2964
+ const video = widget.popupElements?.video;
2965
+ const history = [];
2966
+ const start = performance.now();
2967
+
2968
+ while (performance.now() - start < MOTION_CAPTURE_MS) {
2969
+ const r = track(video, performance.now());
2970
+ if (r && r.faceCount === 1) {
2971
+ history.push({
2972
+ ts: r.timestampMs,
2973
+ headPose: r.headPose,
2974
+ blendshapes: r.blendshapes,
2975
+ boundingBox: r.boundingBox,
2976
+ });
2977
+ }
2978
+ await sleep(MOTION_SAMPLE_MS);
2979
+ }
2980
+
2981
+ return {
2982
+ faceCount: history.length > 0 ? 1 : 0,
2983
+ motionHistory: history,
2984
+ };
2985
+ }
2986
+
2987
+ function emitResult(widget, emitter, result) {
2988
+ const params = widget.params;
2989
+
2990
+ if (result.outcome === 'pass') {
2991
+ widget.token = result.token || null;
2992
+ widget.showResult('pass', 'Verified');
2993
+ emitter.emit('verified', result.token, widget.id);
2994
+ params.callback?.(result.token);
2995
+ } else {
2996
+ widget.showResult('fail', 'Verification failed');
2997
+ emitter.emit('error', 'failed', widget.id);
2998
+ params.errorCallback?.('failed');
2999
+ }
3000
+ }
3001
+
3002
+ function waitForStart(widget) {
3003
+ return new Promise((resolve) => {
3004
+ widget.onStartClick = () => {
3005
+ widget.onStartClick = null;
3006
+ resolve();
3007
+ };
3008
+ });
3009
+ }
3010
+
3011
+ async function startCameraFlow(widget, modelBuffer) {
3012
+ const video = widget.showCamera();
3013
+ widget.setVideoStatus('Requesting camera…');
3014
+ await startCamera(video);
3015
+
3016
+ widget.setVideoStatus('Preparing…');
3017
+ await initTracker(modelBuffer);
3018
+
3019
+ widget.setVideoStatus('Position your face');
3020
+ await waitForPositioning(video, widget);
3021
+ }
3022
+
3023
+ function waitForPositioning(video, widget) {
3024
+ return new Promise((resolve, reject) => {
3025
+ const handle = startPositioning(video, {
3026
+ onStatus: (text) => widget.setVideoStatus(text),
3027
+ onReady: () => resolve(),
3028
+ });
3029
+
3030
+ setTimeout(() => {
3031
+ handle.cancel();
3032
+ reject(new Error('Positioning timeout'));
3033
+ }, 30000);
3034
+ });
3035
+ }
3036
+
3037
+ async function runLivenessLoop(video, session, widget) {
3038
+ return new Promise((resolve) => {
3039
+ const loop = () => {
3040
+ const tracking = track(video, performance.now());
3041
+ if (tracking) processFrame(session, tracking);
3042
+
3043
+ widget.setInstruction(currentInstruction(session) || 'Done');
3044
+ widget.setTask(currentTaskId(session));
3045
+ widget.setProgress(progress(session));
3046
+ widget.setVideoStatus(
3047
+ `Check ` +
3048
+ `${Math.min(session.currentIndex + 1, session.tasks.length)}` +
3049
+ ` of ${session.tasks.length}`
3050
+ );
3051
+
3052
+ if (session.failed || isComplete(session)) {
3053
+ resolve();
3054
+ return;
3055
+ }
3056
+
3057
+ requestAnimationFrame(loop);
3058
+ };
3059
+
3060
+ requestAnimationFrame(loop);
3061
+ });
3062
+ }
3063
+
3064
+ async function captureFrameBurst(count, interval) {
3065
+ const frames = [];
3066
+ for (let i = 0; i < count; i++) {
3067
+ const frame = captureFrame();
3068
+ if (frame) frames.push(frame);
3069
+ if (i < count - 1) await sleep(interval);
3070
+ }
3071
+ return frames;
3072
+ }
3073
+
3074
+ function cleanupLocal() {
3075
+ stopCamera();
3076
+ destroyTracker();
3077
+ }
3078
+
3079
+ function cleanupVM(transport) {
3080
+ stopCamera();
3081
+ removeMirrorVideo();
3082
+ destroyTracker();
3083
+ unregisterBridge();
3084
+ destroyVM();
3085
+ clearModelCache();
3086
+ transport?.close();
3087
+ }
3088
+
3089
+ function exposeMirrorVideo(source) {
3090
+ removeMirrorVideo();
3091
+ if (!source?.srcObject) return;
3092
+ const mirror = document.createElement('video');
3093
+ mirror.id = '__openage_mirror';
3094
+ mirror.srcObject = source.srcObject;
3095
+ mirror.autoplay = true;
3096
+ mirror.muted = true;
3097
+ mirror.playsInline = true;
3098
+ mirror.style.cssText =
3099
+ 'position:fixed;width:1px;height:1px;' + 'opacity:0;pointer-events:none;z-index:-1;';
3100
+ document.body.appendChild(mirror);
3101
+ }
3102
+
3103
+ function removeMirrorVideo() {
3104
+ document.getElementById('__openage_mirror')?.remove();
3105
+ }
3106
+
3107
+ class EventEmitter {
3108
+ constructor() {
3109
+ this.listeners = new Map();
3110
+ }
3111
+
3112
+ on(event, handler) {
3113
+ if (!this.listeners.has(event)) {
3114
+ this.listeners.set(event, new Set());
3115
+ }
3116
+ this.listeners.get(event).add(handler);
3117
+ return this;
3118
+ }
3119
+
3120
+ off(event, handler) {
3121
+ const handlers = this.listeners.get(event);
3122
+ if (handlers) handlers.delete(handler);
3123
+ return this;
3124
+ }
3125
+
3126
+ once(event, handler) {
3127
+ const wrapper = (...args) => {
3128
+ this.off(event, wrapper);
3129
+ handler(...args);
3130
+ };
3131
+ wrapper._original = handler;
3132
+ return this.on(event, wrapper);
3133
+ }
3134
+
3135
+ emit(event, ...args) {
3136
+ const handlers = this.listeners.get(event);
3137
+ if (!handlers) return;
3138
+ for (const handler of [...handlers]) {
3139
+ handler(...args);
3140
+ }
3141
+ }
3142
+
3143
+ removeAllListeners(event) {
3144
+ if (event) {
3145
+ this.listeners.delete(event);
3146
+ } else {
3147
+ this.listeners.clear();
3148
+ }
3149
+ return this;
3150
+ }
3151
+ }
3152
+
3153
+ const emitter = new EventEmitter();
3154
+ const widgets = new Map();
3155
+
3156
+ function normalizeParams(params) {
3157
+ const globalConfig = typeof window !== 'undefined' ? window.openage || {} : {};
3158
+
3159
+ return {
3160
+ mode: 'serverless',
3161
+ theme: 'auto',
3162
+ size: 'normal',
3163
+ minAge: 18,
3164
+ ...globalConfig,
3165
+ ...params,
3166
+ };
3167
+ }
3168
+
3169
+ function startWidget(widget) {
3170
+ widget.onChallenge = () => {
3171
+ emitter.emit('opened', widget.id);
3172
+ runChallenge(widget, emitter);
3173
+ };
3174
+ }
3175
+
3176
+ function render(container, params = {}) {
3177
+ const normalized = normalizeParams(params);
3178
+ const widget = new Widget(container, normalized);
3179
+ widgets.set(widget.id, widget);
3180
+ startWidget(widget);
3181
+ return widget.id;
3182
+ }
3183
+
3184
+ function open(params = {}) {
3185
+ const normalized = normalizeParams(params);
3186
+ const widget = createModalWidget(normalized);
3187
+ widget.anchorElement = normalized.anchorElement || null;
3188
+ widgets.set(widget.id, widget);
3189
+
3190
+ widget.onChallenge = () => {
3191
+ emitter.emit('opened', widget.id);
3192
+ runChallenge(widget, emitter);
3193
+ };
3194
+
3195
+ widget.startChallenge();
3196
+ return widget.id;
3197
+ }
3198
+
3199
+ function bind(element, params = {}) {
3200
+ const normalized = normalizeParams(params);
3201
+ const target = typeof element === 'string' ? document.querySelector(element) : element;
3202
+
3203
+ if (!target) {
3204
+ throw new Error('OpenAge: element not found');
3205
+ }
3206
+
3207
+ let isReplayingClick = false;
3208
+ let activeWidgetId = null;
3209
+
3210
+ const replayTargetClick = () => {
3211
+ isReplayingClick = true;
3212
+ try {
3213
+ target.click();
3214
+ } finally {
3215
+ isReplayingClick = false;
3216
+ }
3217
+ };
3218
+
3219
+ const clearActiveWidget = () => {
3220
+ activeWidgetId = null;
3221
+ };
3222
+
3223
+ const handler = (event) => {
3224
+ if (isReplayingClick || activeWidgetId) {
3225
+ return;
3226
+ }
3227
+
3228
+ event.preventDefault();
3229
+ event.stopPropagation();
3230
+ event.stopImmediatePropagation?.();
3231
+
3232
+ const widgetId = open({
3233
+ ...normalized,
3234
+ anchorElement: target,
3235
+ callback: (token) => {
3236
+ normalized.callback?.(token);
3237
+ clearActiveWidget();
3238
+ replayTargetClick();
3239
+ },
3240
+ errorCallback: (error) => {
3241
+ clearActiveWidget();
3242
+ normalized.errorCallback?.(error);
3243
+ },
3244
+ closeCallback: () => {
3245
+ clearActiveWidget();
3246
+ normalized.closeCallback?.();
3247
+ },
3248
+ });
3249
+
3250
+ activeWidgetId = widgetId;
3251
+
3252
+ return widgetId;
3253
+ };
3254
+
3255
+ target.addEventListener('click', handler, true);
3256
+
3257
+ return () => {
3258
+ target.removeEventListener('click', handler, true);
3259
+ };
3260
+ }
3261
+
3262
+ function reset(widgetId) {
3263
+ widgets.get(widgetId)?.reset();
3264
+ }
3265
+
3266
+ function remove(widgetId) {
3267
+ const widget = widgets.get(widgetId);
3268
+ if (!widget) return;
3269
+ widget.destroy();
3270
+ widgets.delete(widgetId);
3271
+ }
3272
+
3273
+ function getToken(widgetId) {
3274
+ return widgets.get(widgetId)?.getToken() || null;
3275
+ }
3276
+
3277
+ function execute(widgetId) {
3278
+ const widget = widgets.get(widgetId);
3279
+ if (!widget) return;
3280
+ widget.startChallenge();
3281
+ }
3282
+
3283
+ function challenge(params = {}) {
3284
+ return new Promise((resolve, reject) => {
3285
+ open({
3286
+ ...params,
3287
+ callback: (token) => resolve(token),
3288
+ errorCallback: (error) => reject(typeof error === 'string' ? new Error(error) : error),
3289
+ closeCallback: () => reject(new Error('User dismissed')),
3290
+ });
3291
+ });
3292
+ }
3293
+
3294
+ function on(event, handler) {
3295
+ emitter.on(event, handler);
3296
+ }
3297
+
3298
+ function off(event, handler) {
3299
+ emitter.off(event, handler);
3300
+ }
3301
+
3302
+ function once(event, handler) {
3303
+ emitter.once(event, handler);
3304
+ }
3305
+
3306
+ function autoRender() {
3307
+ if (typeof document === 'undefined') return;
3308
+
3309
+ const globalConfig = typeof window !== 'undefined' ? window.openage || {} : {};
3310
+
3311
+ if (globalConfig.render === 'explicit') return;
3312
+
3313
+ const elements = document.querySelectorAll('.openage');
3314
+
3315
+ for (const element of elements) {
3316
+ const params = {
3317
+ sitekey: element.dataset.sitekey,
3318
+ theme: element.dataset.theme,
3319
+ size: element.dataset.size,
3320
+ action: element.dataset.action,
3321
+ mode: element.dataset.mode,
3322
+ server: element.dataset.server,
3323
+ };
3324
+
3325
+ if (element.dataset.callback) {
3326
+ params.callback = (token) => {
3327
+ const fn = window[element.dataset.callback];
3328
+ if (typeof fn === 'function') fn(token);
3329
+ };
3330
+ }
3331
+
3332
+ if (element.dataset.errorCallback) {
3333
+ params.errorCallback = (error) => {
3334
+ const fn = window[element.dataset.errorCallback];
3335
+ if (typeof fn === 'function') fn(error);
3336
+ };
3337
+ }
3338
+
3339
+ if (element.dataset.expiredCallback) {
3340
+ params.expiredCallback = () => {
3341
+ const fn = window[element.dataset.expiredCallback];
3342
+ if (typeof fn === 'function') fn();
3343
+ };
3344
+ }
3345
+
3346
+ if (element.dataset.bind) {
3347
+ const target = document.getElementById(element.dataset.bind);
3348
+ if (target) {
3349
+ bind(target, params);
3350
+ continue;
3351
+ }
3352
+ }
3353
+
3354
+ render(element, params);
3355
+ }
3356
+
3357
+ const onloadName = document.currentScript?.dataset?.onload;
3358
+ if (onloadName && typeof window[onloadName] === 'function') {
3359
+ window[onloadName]();
3360
+ }
3361
+ }
3362
+
3363
+ if (typeof document !== 'undefined') {
3364
+ if (document.readyState === 'loading') {
3365
+ document.addEventListener('DOMContentLoaded', autoRender);
3366
+ } else {
3367
+ autoRender();
3368
+ }
3369
+ }
3370
+
3371
+ const OpenAge = {
3372
+ render,
3373
+ open,
3374
+ bind,
3375
+ reset,
3376
+ remove,
3377
+ getToken,
3378
+ execute,
3379
+ challenge,
3380
+ on,
3381
+ off,
3382
+ once,
3383
+ verify: verifyToken,
3384
+ decode: decodeToken,
3385
+ version: VERSION,
3386
+ };
3387
+
3388
+ exports.EventEmitter = EventEmitter;
3389
+ exports.bind = bind;
3390
+ exports.challenge = challenge;
3391
+ exports.decode = decodeToken;
3392
+ exports.default = OpenAge;
3393
+ exports.execute = execute;
3394
+ exports.getToken = getToken;
3395
+ exports.off = off;
3396
+ exports.on = on;
3397
+ exports.once = once;
3398
+ exports.open = open;
3399
+ exports.remove = remove;
3400
+ exports.render = render;
3401
+ exports.reset = reset;
3402
+ exports.verify = verifyToken;
3403
+ exports.version = VERSION;
3404
+
3405
+ Object.defineProperty(exports, '__esModule', { value: true });
3406
+
3407
+ }));
3408
+ //# sourceMappingURL=openage.umd.js.map