@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.
package/src/widget.js ADDED
@@ -0,0 +1,617 @@
1
+ import {
2
+ STYLES,
3
+ checkboxTemplate,
4
+ heroTemplate,
5
+ challengeTemplate,
6
+ resultTemplate,
7
+ resolveTheme,
8
+ watchTheme,
9
+ SPINNER_SVG,
10
+ CHECK_SVG,
11
+ CLOSE_SVG,
12
+ RETRY_SVG,
13
+ } from './ui.js';
14
+ import { POPUP_MIN_WIDTH, POPUP_MIN_HEIGHT, POPUP_MARGIN } from './constants.js';
15
+
16
+ let widgetCounter = 0;
17
+
18
+ export class Widget {
19
+ constructor(container, params) {
20
+ this.id = `oa-${++widgetCounter}`;
21
+ this.params = params;
22
+ this.container = resolveContainer(container);
23
+ this.anchorElement = null;
24
+ this.state = 'idle';
25
+ this.token = null;
26
+ this.popup = null;
27
+ this.shadow = null;
28
+ this.elements = {};
29
+ this.onChallenge = null;
30
+ this.onStartClick = null;
31
+ this.popupFrame = 0;
32
+ this.themeCleanup = null;
33
+
34
+ this.render();
35
+ }
36
+
37
+ render() {
38
+ const host = document.createElement('div');
39
+ host.id = this.id;
40
+ this.shadow = host.attachShadow({ mode: 'open' });
41
+
42
+ const style = document.createElement('style');
43
+ style.textContent = STYLES;
44
+ this.shadow.appendChild(style);
45
+
46
+ const theme = resolveTheme(this.params.theme);
47
+ host.setAttribute('data-theme', theme);
48
+ this.themeCleanup = watchTheme(host, this.params.theme);
49
+
50
+ if (this.params.size === 'invisible') {
51
+ host.style.display = 'none';
52
+ this.container.appendChild(host);
53
+ this.host = host;
54
+ return;
55
+ }
56
+
57
+ const label = 'I am of age';
58
+
59
+ const wrapper = document.createElement('div');
60
+ wrapper.innerHTML = checkboxTemplate(label);
61
+ this.shadow.appendChild(wrapper.firstElementChild);
62
+
63
+ const checkbox = this.shadow.querySelector('.oa-checkbox');
64
+
65
+ if (this.params.size === 'compact') {
66
+ checkbox.classList.add('oa-compact');
67
+ }
68
+
69
+ checkbox.addEventListener('click', (event) => {
70
+ if (event.target.closest('a')) return;
71
+ if (this.state === 'verified') return;
72
+ if (this.state === 'loading') return;
73
+ this.clearError();
74
+ this.startChallenge();
75
+ });
76
+
77
+ checkbox.addEventListener('keydown', (event) => {
78
+ if (event.key === 'Enter' || event.key === ' ') {
79
+ event.preventDefault();
80
+ checkbox.click();
81
+ }
82
+ });
83
+
84
+ this.elements.checkbox = checkbox;
85
+ this.elements.checkBox = this.shadow.querySelector('.oa-check-box');
86
+ this.elements.errorSlot = this.shadow.querySelector('.oa-error-slot');
87
+
88
+ this.container.appendChild(host);
89
+ this.host = host;
90
+ }
91
+
92
+ startChallenge() {
93
+ this.setState('loading');
94
+
95
+ if (this.onChallenge) {
96
+ this.onChallenge(this);
97
+ }
98
+ }
99
+
100
+ createPopupShell() {
101
+ const theme = resolveTheme(this.params.theme);
102
+ const popupHost = document.createElement('div');
103
+ popupHost.setAttribute('data-theme', theme);
104
+ const popupShadow = popupHost.attachShadow({
105
+ mode: 'open',
106
+ });
107
+
108
+ const style = document.createElement('style');
109
+ style.textContent = STYLES;
110
+ popupShadow.appendChild(style);
111
+
112
+ const themeCleanup = watchTheme(popupHost, this.params.theme);
113
+
114
+ return {
115
+ popupHost,
116
+ popupShadow,
117
+ themeCleanup,
118
+ };
119
+ }
120
+
121
+ openPopup() {
122
+ if (this.popup) return this.getVideo();
123
+
124
+ const anchor = this.getPopupAnchor();
125
+
126
+ if (!anchor) {
127
+ return this.openModal();
128
+ }
129
+
130
+ const { popupHost, popupShadow, themeCleanup } = this.createPopupShell();
131
+
132
+ const popup = document.createElement('div');
133
+ popup.className = 'oa-popup';
134
+ popup.innerHTML = this.buildPopupContent();
135
+ popup.style.visibility = 'hidden';
136
+ popup.style.pointerEvents = 'none';
137
+
138
+ popupShadow.appendChild(popup);
139
+ document.body.appendChild(popupHost);
140
+
141
+ this.popup = {
142
+ anchor,
143
+ host: popupHost,
144
+ root: popup,
145
+ themeCleanup,
146
+ };
147
+
148
+ const anchorRect = anchor.getBoundingClientRect();
149
+ const popupRect = popup.getBoundingClientRect();
150
+ const position = findPopupPosition(anchorRect, popupRect);
151
+
152
+ if (position.mode === 'modal') {
153
+ popupHost.remove();
154
+ themeCleanup?.();
155
+ this.popup = null;
156
+ return this.openModal();
157
+ }
158
+
159
+ this.bindPopupEvents(popup, popupShadow);
160
+ this.updatePopupPosition();
161
+ this.startPopupTracking();
162
+ return this.getVideo();
163
+ }
164
+
165
+ openModal() {
166
+ const { popupHost, popupShadow, themeCleanup } = this.createPopupShell();
167
+
168
+ const overlay = document.createElement('div');
169
+ overlay.className = 'oa-modal-overlay';
170
+
171
+ const modal = document.createElement('div');
172
+ modal.className = 'oa-modal';
173
+ modal.innerHTML = this.buildPopupContent();
174
+
175
+ overlay.appendChild(modal);
176
+ popupShadow.appendChild(overlay);
177
+ document.body.appendChild(popupHost);
178
+
179
+ overlay.addEventListener('click', (event) => {
180
+ if (event.target === overlay) this.closePopup();
181
+ });
182
+
183
+ this.popup = {
184
+ host: popupHost,
185
+ root: modal,
186
+ overlay,
187
+ themeCleanup,
188
+ };
189
+ this.bindPopupEvents(modal, popupShadow);
190
+ return this.getVideo();
191
+ }
192
+
193
+ getPopupAnchor() {
194
+ return this.anchorElement || this.elements.checkbox || this.host || null;
195
+ }
196
+
197
+ startPopupTracking() {
198
+ if (!this.popup || this.popup.overlay) return;
199
+
200
+ const schedule = () => {
201
+ this.schedulePopupPosition();
202
+ };
203
+
204
+ const cleanups = [];
205
+ const addWindowListener = (name, options) => {
206
+ window.addEventListener(name, schedule, options);
207
+ cleanups.push(() => {
208
+ window.removeEventListener(name, schedule, options);
209
+ });
210
+ };
211
+
212
+ addWindowListener('resize', { passive: true });
213
+ addWindowListener('scroll', {
214
+ capture: true,
215
+ passive: true,
216
+ });
217
+
218
+ if (window.visualViewport) {
219
+ const viewport = window.visualViewport;
220
+ viewport.addEventListener('resize', schedule);
221
+ viewport.addEventListener('scroll', schedule);
222
+ cleanups.push(() => {
223
+ viewport.removeEventListener('resize', schedule);
224
+ viewport.removeEventListener('scroll', schedule);
225
+ });
226
+ }
227
+
228
+ if (typeof ResizeObserver === 'function') {
229
+ const observer = new ResizeObserver(() => {
230
+ schedule();
231
+ });
232
+ observer.observe(this.popup.root);
233
+ observer.observe(this.popup.anchor);
234
+ observer.observe(document.documentElement);
235
+ if (document.body) {
236
+ observer.observe(document.body);
237
+ }
238
+ cleanups.push(() => observer.disconnect());
239
+ }
240
+
241
+ this.popup.cleanup = () => {
242
+ if (this.popupFrame) {
243
+ cancelAnimationFrame(this.popupFrame);
244
+ this.popupFrame = 0;
245
+ }
246
+ for (const cleanup of cleanups) {
247
+ cleanup();
248
+ }
249
+ };
250
+ }
251
+
252
+ schedulePopupPosition() {
253
+ if (!this.popup || this.popup.overlay) return;
254
+ if (this.popupFrame) return;
255
+
256
+ this.popupFrame = requestAnimationFrame(() => {
257
+ this.popupFrame = 0;
258
+ this.updatePopupPosition();
259
+ });
260
+ }
261
+
262
+ updatePopupPosition() {
263
+ if (!this.popup || this.popup.overlay) return;
264
+
265
+ const anchor = this.getPopupAnchor();
266
+ if (!anchor || !anchor.isConnected) {
267
+ this.closePopup();
268
+ return;
269
+ }
270
+
271
+ this.popup.anchor = anchor;
272
+
273
+ const anchorRect = anchor.getBoundingClientRect();
274
+ const popupRect = this.popup.root.getBoundingClientRect();
275
+ const position = findPopupPosition(anchorRect, popupRect);
276
+
277
+ if (position.mode === 'modal') {
278
+ this.closePopup();
279
+ this.openModal();
280
+ return;
281
+ }
282
+
283
+ const top = `${Math.round(position.top)}px`;
284
+ const left = `${Math.round(position.left)}px`;
285
+
286
+ if (this.popup.root.style.top !== top) {
287
+ this.popup.root.style.top = top;
288
+ }
289
+
290
+ if (this.popup.root.style.left !== left) {
291
+ this.popup.root.style.left = left;
292
+ }
293
+
294
+ this.popup.root.dataset.placement = position.placement;
295
+ this.popup.root.style.visibility = 'visible';
296
+ this.popup.root.style.pointerEvents = 'auto';
297
+ }
298
+
299
+ buildPopupContent() {
300
+ return `
301
+ <div class="oa-header">
302
+ <div class="oa-title">
303
+ <a class="oa-logo"
304
+ href="https://github.com/tn3w/OpenAge"
305
+ target="_blank" rel="noopener">
306
+ Open<strong>Age</strong>
307
+ </a>
308
+ <span class="oa-badge">on-device</span>
309
+ </div>
310
+ <button class="oa-close-btn"
311
+ aria-label="Close">
312
+ ${CLOSE_SVG}
313
+ </button>
314
+ </div>
315
+ <div class="oa-body">
316
+ ${heroTemplate('Initializing…')}
317
+ </div>
318
+ <div class="oa-actions oa-hidden">
319
+ <button class="oa-btn oa-start-btn">
320
+ Begin Verification
321
+ </button>
322
+ </div>
323
+ `;
324
+ }
325
+
326
+ bindPopupEvents(root, shadow) {
327
+ const closeBtn = root.querySelector('.oa-close-btn');
328
+ if (closeBtn) {
329
+ closeBtn.addEventListener('click', () => {
330
+ this.closePopup();
331
+ this.params.closeCallback?.();
332
+ });
333
+ }
334
+
335
+ const startBtn = root.querySelector('.oa-start-btn');
336
+ if (startBtn) {
337
+ startBtn.addEventListener('click', () => {
338
+ if (this.onStartClick) this.onStartClick();
339
+ });
340
+ }
341
+
342
+ this.popupElements = {
343
+ body: root.querySelector('.oa-body'),
344
+ actions: root.querySelector('.oa-actions'),
345
+ startBtn,
346
+ heroStatus: root.querySelector('.oa-hero-status'),
347
+ };
348
+ }
349
+
350
+ getVideo() {
351
+ if (!this.popup) return null;
352
+ return this.popup.root.querySelector('video');
353
+ }
354
+
355
+ showHero(statusText) {
356
+ if (!this.popupElements?.body) return;
357
+ this.popupElements.body.innerHTML = heroTemplate(statusText);
358
+ this.popupElements.heroStatus = this.popupElements.body.querySelector('.oa-hero-status');
359
+ this.hideActions();
360
+ }
361
+
362
+ showReady() {
363
+ this.setHeroStatus('Ready to verify your age.');
364
+ this.showActions('Begin Verification');
365
+ }
366
+
367
+ showCamera() {
368
+ if (!this.popupElements?.body) return;
369
+ this.popupElements.body.innerHTML = challengeTemplate();
370
+
371
+ this.popupElements.video = this.popupElements.body.querySelector('video');
372
+ this.popupElements.faceGuide = this.popupElements.body.querySelector('.oa-face-guide');
373
+ this.popupElements.challengeHud =
374
+ this.popupElements.body.querySelector('.oa-challenge-hud');
375
+ this.popupElements.challengeText =
376
+ this.popupElements.body.querySelector('.oa-challenge-text');
377
+ this.popupElements.challengeFill =
378
+ this.popupElements.body.querySelector('.oa-challenge-fill');
379
+ this.popupElements.videoStatus =
380
+ this.popupElements.body.querySelector('.oa-video-status p');
381
+
382
+ this.hideActions();
383
+ return this.popupElements.video;
384
+ }
385
+
386
+ showLiveness() {
387
+ if (this.popupElements?.faceGuide) {
388
+ this.popupElements.faceGuide.classList.remove('oa-hidden');
389
+ }
390
+ if (this.popupElements?.challengeHud) {
391
+ this.popupElements.challengeHud.classList.remove('oa-hidden');
392
+ }
393
+ }
394
+
395
+ setHeroStatus(text) {
396
+ if (this.popupElements?.heroStatus) {
397
+ this.popupElements.heroStatus.textContent = text;
398
+ }
399
+ }
400
+
401
+ setVideoStatus(text) {
402
+ if (this.popupElements?.videoStatus) {
403
+ this.popupElements.videoStatus.textContent = text;
404
+ }
405
+ }
406
+
407
+ setInstruction(text) {
408
+ if (this.popupElements?.challengeText) {
409
+ this.popupElements.challengeText.textContent = text;
410
+ }
411
+ }
412
+
413
+ setStatus(text) {
414
+ this.setVideoStatus(text);
415
+ }
416
+
417
+ setProgress(fraction) {
418
+ if (this.popupElements?.challengeFill) {
419
+ this.popupElements.challengeFill.style.width = `${Math.round(fraction * 100)}%`;
420
+ }
421
+ }
422
+
423
+ setTask(taskId) {
424
+ if (this.popupElements?.faceGuide) {
425
+ this.popupElements.faceGuide.setAttribute('data-task', taskId || '');
426
+ }
427
+ }
428
+
429
+ showActions(label) {
430
+ if (!this.popupElements?.actions) return;
431
+ this.popupElements.actions.classList.remove('oa-hidden');
432
+ if (this.popupElements.startBtn) {
433
+ this.popupElements.startBtn.textContent = label;
434
+ }
435
+ }
436
+
437
+ hideActions() {
438
+ if (this.popupElements?.actions) {
439
+ this.popupElements.actions.classList.add('oa-hidden');
440
+ }
441
+ }
442
+
443
+ showResult(outcome, message) {
444
+ if (outcome === 'pass') {
445
+ this.closePopup();
446
+ this.setState('verified');
447
+ return;
448
+ }
449
+
450
+ if (outcome === 'fail') {
451
+ if (this.params.size === 'invisible') {
452
+ if (this.popupElements?.body) {
453
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
454
+ }
455
+ this.hideActions();
456
+ this.showActions('Try Again');
457
+ } else {
458
+ this.closePopup();
459
+ this.setState('retry');
460
+ }
461
+ return;
462
+ }
463
+
464
+ if (outcome === 'retry') {
465
+ if (this.params.size !== 'invisible') {
466
+ this.closePopup();
467
+ this.setState('retry');
468
+ return;
469
+ }
470
+
471
+ if (this.popupElements?.body) {
472
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
473
+ }
474
+ this.hideActions();
475
+ this.showActions('Try Again');
476
+ }
477
+ }
478
+
479
+ showError() {
480
+ this.setState('retry');
481
+ }
482
+
483
+ clearError() {
484
+ if (this.elements.errorSlot) {
485
+ this.elements.errorSlot.innerHTML = '';
486
+ }
487
+ }
488
+
489
+ closePopup() {
490
+ if (!this.popup) return;
491
+ this.popup.cleanup?.();
492
+ this.popup.themeCleanup?.();
493
+ this.popup.host.remove();
494
+ this.popup = null;
495
+ this.popupElements = null;
496
+
497
+ if (this.state === 'loading') {
498
+ this.setState('idle');
499
+ }
500
+ }
501
+
502
+ setState(newState) {
503
+ this.state = newState;
504
+ const cb = this.elements.checkbox;
505
+ const box = this.elements.checkBox;
506
+ if (!cb || !box) return;
507
+
508
+ cb.classList.remove('oa-loading', 'oa-verified', 'oa-failed', 'oa-retry', 'oa-expired');
509
+ cb.setAttribute('aria-checked', 'false');
510
+ box.innerHTML = '';
511
+
512
+ switch (newState) {
513
+ case 'loading':
514
+ cb.classList.add('oa-loading');
515
+ box.innerHTML = `<span class="oa-spinner">` + `${SPINNER_SVG}</span>`;
516
+ break;
517
+ case 'verified':
518
+ cb.classList.add('oa-verified');
519
+ cb.setAttribute('aria-checked', 'true');
520
+ box.innerHTML = CHECK_SVG;
521
+ break;
522
+ case 'failed':
523
+ cb.classList.add('oa-failed');
524
+ box.innerHTML = '✕';
525
+ break;
526
+ case 'retry':
527
+ cb.classList.add('oa-retry');
528
+ box.innerHTML = RETRY_SVG;
529
+ break;
530
+ case 'expired':
531
+ cb.classList.add('oa-expired');
532
+ cb.setAttribute('aria-checked', 'false');
533
+ break;
534
+ default:
535
+ break;
536
+ }
537
+ }
538
+
539
+ getToken() {
540
+ return this.token;
541
+ }
542
+
543
+ reset() {
544
+ this.token = null;
545
+ this.closePopup();
546
+ this.setState('idle');
547
+ }
548
+
549
+ destroy() {
550
+ this.closePopup();
551
+ this.themeCleanup?.();
552
+ this.host?.remove();
553
+ }
554
+ }
555
+
556
+ export function createModalWidget(params) {
557
+ const widget = new Widget(document.createElement('div'), { ...params, size: 'invisible' });
558
+ return widget;
559
+ }
560
+
561
+ function resolveContainer(container) {
562
+ if (typeof container === 'string') {
563
+ return document.querySelector(container);
564
+ }
565
+ return container;
566
+ }
567
+
568
+ function findPopupPosition(anchorRect, popupRect) {
569
+ const viewport = {
570
+ width: window.innerWidth,
571
+ height: window.innerHeight,
572
+ };
573
+
574
+ const popupWidth = Math.max(popupRect.width || 0, POPUP_MIN_WIDTH);
575
+ const popupHeight = Math.max(popupRect.height || 0, POPUP_MIN_HEIGHT);
576
+ const availableWidth = viewport.width - POPUP_MARGIN * 2;
577
+ const availableHeight = viewport.height - POPUP_MARGIN * 2;
578
+
579
+ if (popupWidth > availableWidth || popupHeight > availableHeight) {
580
+ return { mode: 'modal' };
581
+ }
582
+
583
+ const left = clampLeft(
584
+ anchorRect.left + anchorRect.width / 2 - popupWidth / 2,
585
+ viewport.width,
586
+ popupWidth
587
+ );
588
+
589
+ const topBelow = anchorRect.bottom + POPUP_MARGIN;
590
+ const topAbove = anchorRect.top - popupHeight - POPUP_MARGIN;
591
+ const fitsBelow = topBelow + popupHeight <= viewport.height - POPUP_MARGIN;
592
+ const fitsAbove = topAbove >= POPUP_MARGIN;
593
+
594
+ if (fitsBelow || !fitsAbove) {
595
+ return {
596
+ mode: 'popup',
597
+ placement: 'below',
598
+ top: clampTop(topBelow, viewport.height, popupHeight),
599
+ left,
600
+ };
601
+ }
602
+
603
+ return {
604
+ mode: 'popup',
605
+ placement: 'above',
606
+ top: clampTop(topAbove, viewport.height, popupHeight),
607
+ left,
608
+ };
609
+ }
610
+
611
+ function clampLeft(left, viewportWidth, popupWidth) {
612
+ return Math.min(Math.max(POPUP_MARGIN, left), viewportWidth - popupWidth - POPUP_MARGIN);
613
+ }
614
+
615
+ function clampTop(top, viewportHeight, popupHeight) {
616
+ return Math.min(Math.max(POPUP_MARGIN, top), viewportHeight - popupHeight - POPUP_MARGIN);
617
+ }