@modernman00/shared-js-lib 1.2.90 → 1.6.2

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,312 @@
1
+ /**
2
+ * vse-canvas.js
3
+ *
4
+ * Next-Gen Visual Supremacy Engine (VSE) Client Runtime
5
+ *
6
+ * Features:
7
+ * - Dynamic P3 wide colour gamut and HDR capability detection
8
+ * - 2.5D tactile gyroscopic micro-parallax with spring damping
9
+ * - Dr. Viktor Kovács & Marcus security killswitch (suspends sensors on input focus)
10
+ * - Accessibility & reduced-motion invariant compliance
11
+ * - 120Hz ProMotion frame pacing via requestAnimationFrame
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ class VisualSupremacyRuntime {
17
+ constructor() {
18
+ this.isP3 = false;
19
+ this.isHDR = false;
20
+ this.reducedMotion = false;
21
+ this.isLowPowerMode = false;
22
+ this.isSensorActive = false;
23
+ this.isInputFocused = false;
24
+ this.orientation = { gamma: 0, beta: 0 };
25
+ this.targetOrientation = { gamma: 0, beta: 0 };
26
+ this.rafId = null;
27
+ this.activeCards = new Set();
28
+ this.intersectionObserver = null;
29
+ }
30
+
31
+ init() {
32
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
33
+ return;
34
+ }
35
+
36
+ // 1. Telemetry: Wide Colour Gamut & Dynamic Range Detection
37
+ try {
38
+ this.isP3 = window.matchMedia && window.matchMedia('(color-gamut: p3)').matches;
39
+ this.isHDR = window.matchMedia && window.matchMedia('(dynamic-range: high)').matches;
40
+ this.reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
41
+
42
+ const root = document.documentElement;
43
+ if (this.isP3) {
44
+ root.classList.add('vse-p3-capable');
45
+ }
46
+ if (this.isHDR) {
47
+ root.classList.add('vse-hdr-capable');
48
+ }
49
+
50
+ // Watch for live reduced motion preference changes
51
+ if (window.matchMedia) {
52
+ window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
53
+ this.reducedMotion = e.matches;
54
+ if (this.reducedMotion) {
55
+ this.resetTransforms();
56
+ this.stopSensors();
57
+ } else if (!this.isLowPowerMode) {
58
+ this.startSensors();
59
+ }
60
+ });
61
+ }
62
+ } catch (_) {}
63
+
64
+ if (this.reducedMotion) {
65
+ return;
66
+ }
67
+
68
+ // 2. Segun & Victor PWA Battery-Saver & Low-Memory Killswitch
69
+ this.setupBatteryAndMemoryGuard();
70
+
71
+ // 3. Observe media cards using IntersectionObserver to only animate visible elements
72
+ this.setupCardObserver();
73
+
74
+ // 4. Mandatory Adversarial Security Gate (Dr. Viktor Kovács & Marcus): Input Focus Guard
75
+ this.setupSecurityGuard();
76
+
77
+ // 5. Desktop Pointer Parallax Fallback
78
+ this.setupDesktopPointerParallax();
79
+
80
+ // 6. Initialize Sensor Pipeline
81
+ this.startSensors();
82
+ }
83
+
84
+ setupBatteryAndMemoryGuard() {
85
+ try {
86
+ // Low-end Android / memory-constrained device guard (< 2GB RAM)
87
+ if (typeof navigator !== 'undefined' && navigator.deviceMemory && navigator.deviceMemory < 2) {
88
+ this.isLowPowerMode = true;
89
+ this.stopSensors();
90
+ this.resetTransforms();
91
+ return;
92
+ }
93
+
94
+ // Battery Status API guard: Disable 2.5D sensors if battery <= 20% and not charging
95
+ if (typeof navigator !== 'undefined' && typeof navigator.getBattery === 'function') {
96
+ navigator.getBattery().then((battery) => {
97
+ const checkBattery = () => {
98
+ const low = !battery.charging && battery.level <= 0.20;
99
+ this.isLowPowerMode = low;
100
+ if (low) {
101
+ this.stopSensors();
102
+ this.resetTransforms();
103
+ } else if (!this.reducedMotion && !this.isInputFocused && !document.hidden) {
104
+ this.startSensors();
105
+ }
106
+ };
107
+ checkBattery();
108
+ battery.addEventListener('levelchange', checkBattery);
109
+ battery.addEventListener('chargingchange', checkBattery);
110
+ }).catch(() => {});
111
+ }
112
+ } catch (_) {}
113
+ }
114
+
115
+ setupCardObserver() {
116
+ if (!('IntersectionObserver' in window)) {
117
+ return;
118
+ }
119
+
120
+ this.intersectionObserver = new IntersectionObserver((entries) => {
121
+ entries.forEach((entry) => {
122
+ if (entry.isIntersecting) {
123
+ this.activeCards.add(entry.target);
124
+ } else {
125
+ this.activeCards.delete(entry.target);
126
+ // Reset transform when scrolling out of viewport to save GPU compositing
127
+ this.resetCardTransform(entry.target);
128
+ }
129
+ });
130
+ }, { threshold: 0.1 });
131
+
132
+ // Auto-observe post-media-stage containers
133
+ this.refreshObservedCards();
134
+
135
+ // Re-check for dynamically inserted posts (e.g. infinite scroll, new post submission)
136
+ const observer = new MutationObserver(() => this.refreshObservedCards());
137
+ observer.observe(document.body, { childList: true, subtree: true });
138
+ }
139
+
140
+ refreshObservedCards() {
141
+ if (!this.intersectionObserver) return;
142
+ const stages = document.querySelectorAll('.post-media-stage:not([data-vse-observed])');
143
+ stages.forEach((el) => {
144
+ el.setAttribute('data-vse-observed', 'true');
145
+ this.intersectionObserver.observe(el);
146
+ });
147
+ }
148
+
149
+ setupSecurityGuard() {
150
+ // Gyroscopic Timing Side-Channel Defense: Immediately detach listeners on active input
151
+ const pauseEvents = ['focusin', 'focus'];
152
+ const resumeEvents = ['focusout', 'blur'];
153
+
154
+ pauseEvents.forEach((ev) => {
155
+ document.addEventListener(ev, (e) => {
156
+ const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : '';
157
+ const isEditable = e.target && (e.target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select');
158
+ if (isEditable) {
159
+ this.isInputFocused = true;
160
+ this.stopSensors();
161
+ this.resetTransforms();
162
+ }
163
+ }, true);
164
+ });
165
+
166
+ resumeEvents.forEach((ev) => {
167
+ document.addEventListener(ev, () => {
168
+ this.isInputFocused = false;
169
+ if (!document.hidden && !this.reducedMotion) {
170
+ this.startSensors();
171
+ }
172
+ }, true);
173
+ });
174
+
175
+ // Tab Visibility Guard
176
+ document.addEventListener('visibilitychange', () => {
177
+ if (document.hidden) {
178
+ this.stopSensors();
179
+ this.resetTransforms();
180
+ } else if (!this.isInputFocused && !this.reducedMotion) {
181
+ this.startSensors();
182
+ }
183
+ });
184
+ }
185
+
186
+ startSensors() {
187
+ if (this.isSensorActive || this.reducedMotion || this.isInputFocused || this.isLowPowerMode) {
188
+ return;
189
+ }
190
+
191
+ if (typeof window.DeviceOrientationEvent !== 'undefined') {
192
+ this.orientationHandler = (e) => this.handleDeviceOrientation(e);
193
+ window.addEventListener('deviceorientation', this.orientationHandler, { passive: true });
194
+ this.isSensorActive = true;
195
+ this.scheduleRenderLoop();
196
+ }
197
+ }
198
+
199
+ stopSensors() {
200
+ if (!this.isSensorActive) {
201
+ return;
202
+ }
203
+
204
+ if (this.orientationHandler) {
205
+ window.removeEventListener('deviceorientation', this.orientationHandler);
206
+ this.orientationHandler = null;
207
+ }
208
+ if (this.rafId) {
209
+ cancelAnimationFrame(this.rafId);
210
+ this.rafId = null;
211
+ }
212
+ this.isSensorActive = false;
213
+ }
214
+
215
+ handleDeviceOrientation(event) {
216
+ if (this.isInputFocused || this.reducedMotion || this.isLowPowerMode) return;
217
+
218
+ // Gamma: Left to Right (-90 to 90), Beta: Front to Back (-180 to 180)
219
+ let g = event.gamma || 0;
220
+ let b = event.beta || 0;
221
+
222
+ // Normalise resting hand angle: smartphones are typically held tilted forward by ~45 degrees
223
+ b = b - 45;
224
+
225
+ // Clamp to safe, subtle displacement (±15 degrees input, mapping to ±3.5 degrees visual rotation)
226
+ const clamp = (val, max) => Math.max(-max, Math.min(max, val));
227
+ this.targetOrientation.gamma = clamp(g, 15);
228
+ this.targetOrientation.beta = clamp(b, 15);
229
+ }
230
+
231
+ setupDesktopPointerParallax() {
232
+ // For non-touch/pointer devices, provide a subtle interactive hover depth response
233
+ if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
234
+ document.addEventListener('mousemove', (e) => {
235
+ if (this.isInputFocused || this.reducedMotion || this.activeCards.size === 0) return;
236
+
237
+ this.activeCards.forEach((card) => {
238
+ const rect = card.getBoundingClientRect();
239
+ if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
240
+ const relX = ((e.clientX - rect.left) / rect.width) * 2 - 1; // -1 to 1
241
+ const relY = ((e.clientY - rect.top) / rect.height) * 2 - 1; // -1 to 1
242
+
243
+ const rotX = -relY * 3.5;
244
+ const rotY = relX * 3.5;
245
+ const transX = relX * 4;
246
+ const transY = relY * 4;
247
+
248
+ const img = card.querySelector('.post-media-first-img');
249
+ if (img) {
250
+ img.style.transform = `perspective(800px) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg) translate3d(${transX.toFixed(1)}px, ${transY.toFixed(1)}px, 6px) scale(1.02)`;
251
+ img.style.transition = 'transform 0.12s cubic-bezier(0.2, 0, 0, 1)';
252
+ }
253
+ } else {
254
+ this.resetCardTransform(card);
255
+ }
256
+ });
257
+ }, { passive: true });
258
+ }
259
+ }
260
+
261
+ scheduleRenderLoop() {
262
+ if (this.rafId) return;
263
+
264
+ const render = () => {
265
+ // Spring damping / low-pass filter for silky 120fps motion without jitter
266
+ const ease = 0.08;
267
+ this.orientation.gamma += (this.targetOrientation.gamma - this.orientation.gamma) * ease;
268
+ this.orientation.beta += (this.targetOrientation.beta - this.orientation.beta) * ease;
269
+
270
+ const rotY = (this.orientation.gamma / 15) * 3.2; // Max 3.2deg
271
+ const rotX = (-this.orientation.beta / 15) * 3.2; // Max 3.2deg
272
+ const transX = (this.orientation.gamma / 15) * 4.5; // Max 4.5px
273
+ const transY = (this.orientation.beta / 15) * 4.5; // Max 4.5px
274
+
275
+ if (this.activeCards.size > 0 && (Math.abs(rotY) > 0.05 || Math.abs(rotX) > 0.05)) {
276
+ this.activeCards.forEach((card) => {
277
+ const img = card.querySelector('.post-media-first-img');
278
+ if (img) {
279
+ img.style.transform = `perspective(800px) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg) translate3d(${transX.toFixed(1)}px, ${transY.toFixed(1)}px, 5px) scale(1.015)`;
280
+ img.style.transformOrigin = 'center center';
281
+ img.style.willChange = 'transform';
282
+ }
283
+ });
284
+ }
285
+
286
+ this.rafId = requestAnimationFrame(render);
287
+ };
288
+
289
+ this.rafId = requestAnimationFrame(render);
290
+ }
291
+
292
+ resetCardTransform(card) {
293
+ if (!card) return;
294
+ const img = card.querySelector('.post-media-first-img');
295
+ if (img) {
296
+ img.style.transform = '';
297
+ img.style.willChange = 'auto';
298
+ img.style.transition = 'transform 0.3s ease-out';
299
+ }
300
+ }
301
+
302
+ resetTransforms() {
303
+ this.activeCards.forEach((card) => this.resetCardTransform(card));
304
+ this.targetOrientation = { gamma: 0, beta: 0 };
305
+ this.orientation = { gamma: 0, beta: 0 };
306
+ }
307
+ }
308
+
309
+ export const vseRuntime = new VisualSupremacyRuntime();
310
+ export function initVisualSupremacy() {
311
+ vseRuntime.init();
312
+ }
package/WebAuthnClient.js CHANGED
@@ -62,6 +62,13 @@ export class WebAuthnClient {
62
62
  }
63
63
  };
64
64
 
65
+ if (Array.isArray(publicKey.excludeCredentials)) {
66
+ publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
67
+ ...cred,
68
+ id: typeof cred.id === 'string' ? this.base64URLStringToBuffer(cred.id) : cred.id
69
+ }));
70
+ }
71
+
65
72
  const credential = await navigator.credentials.create({ publicKey });
66
73
 
67
74
  return {
@@ -76,7 +83,10 @@ export class WebAuthnClient {
76
83
  } catch (error) {
77
84
  // David's Rule: Defensive Degradation. If it fails or is canceled, log and swallow politely.
78
85
  console.warn("WebAuthn Registration failed or was canceled:", error);
79
- throw new Error("Passkey registration was canceled or failed.");
86
+ const wrapped = new Error("Passkey registration was canceled or failed.");
87
+ wrapped.code = error && error.name ? error.name : 'UnknownError';
88
+ wrapped.originalError = error;
89
+ throw wrapped;
80
90
  }
81
91
  }
82
92
 
@@ -122,4 +132,339 @@ export class WebAuthnClient {
122
132
  throw new Error("Passkey login was canceled or failed. Please use your password.");
123
133
  }
124
134
  }
135
+
136
+ /**
137
+ * Checks if a user-verifying platform authenticator (Face ID, Touch ID, Fingerprint, Windows Hello) is available.
138
+ * Uses PublicKeyCredential.getClientCapabilities() with fallback to isUserVerifyingPlatformAuthenticatorAvailable().
139
+ * @returns {Promise<boolean>}
140
+ */
141
+ static async isPlatformAuthenticatorAvailable() {
142
+ if (!this.isSupported()) {
143
+ return false;
144
+ }
145
+ try {
146
+ if (typeof window.PublicKeyCredential.getClientCapabilities === 'function') {
147
+ const caps = await window.PublicKeyCredential.getClientCapabilities();
148
+ if (caps && typeof caps.userVerifyingPlatformAuthenticator === 'boolean') {
149
+ return caps.userVerifyingPlatformAuthenticator;
150
+ }
151
+ }
152
+ if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
153
+ return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
154
+ }
155
+ } catch (e) {
156
+ console.warn("WebAuthn capability check failed:", e);
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * Detects the platform-appropriate biometric label in British English.
163
+ * @param {string} [ua]
164
+ * @returns {string}
165
+ */
166
+ static detectBiometricLabel(ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '')) {
167
+ const agent = String(ua || '');
168
+ if (/iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(agent)) {
169
+ return 'Face ID or Touch ID';
170
+ }
171
+ if (/Android/i.test(agent)) {
172
+ return 'Fingerprint or Face Unlock';
173
+ }
174
+ if (/Windows/i.test(agent)) {
175
+ return 'Windows Hello';
176
+ }
177
+ return 'Biometric Passkey';
178
+ }
179
+
180
+ /**
181
+ * Determines if the post-login Biometric Passkey Activation UI should appear.
182
+ * @param {Object} state
183
+ * @returns {boolean}
184
+ */
185
+ static shouldShowActivationPrompt(state) {
186
+ if (!state || state.authenticated !== true || state.hasPasskey === true) {
187
+ return false;
188
+ }
189
+ const key = state.userKey || state.userId || 'default';
190
+ try {
191
+ if (localStorage.getItem('biometric_enrolled_' + key) === '1') {
192
+ return false;
193
+ }
194
+ const snoozeUntil = Number(localStorage.getItem('biometric_snooze_' + key) || '0');
195
+ if (snoozeUntil > Date.now()) {
196
+ return false;
197
+ }
198
+ } catch (_) {
199
+ // Ignore storage access errors in strict private modes
200
+ }
201
+ return true;
202
+ }
203
+
204
+ /**
205
+ * Snoozes the activation prompt for the specified number of hours (default 72h).
206
+ * @param {string} userKey
207
+ * @param {number} [hours=72]
208
+ */
209
+ static snoozeActivationPrompt(userKey, hours = 72) {
210
+ if (!userKey) return;
211
+ try {
212
+ const until = Date.now() + hours * 3600 * 1000;
213
+ localStorage.setItem('biometric_snooze_' + userKey, String(until));
214
+ } catch (_) {}
215
+ }
216
+
217
+ /**
218
+ * Marks the current device + user pair as enrolled in localStorage.
219
+ * @param {string} userKey
220
+ */
221
+ static markEnrolled(userKey) {
222
+ if (!userKey) return;
223
+ try {
224
+ localStorage.setItem('biometric_enrolled_' + userKey, '1');
225
+ localStorage.removeItem('biometric_snooze_' + userKey);
226
+ } catch (_) {}
227
+ }
228
+
229
+ /**
230
+ * Renders and mounts the native Biometric Passkey Activation UI modal / bottom-sheet.
231
+ * @param {Object} state
232
+ * @param {Object} [options]
233
+ * @returns {HTMLElement|null}
234
+ */
235
+ static renderPromptModal(state = {}, options = {}) {
236
+ if (typeof document === 'undefined' || !document.body) {
237
+ return null;
238
+ }
239
+ const existing = document.getElementById('universal-passkey-prompt-backdrop');
240
+ if (existing) {
241
+ return existing;
242
+ }
243
+
244
+ const appName = state.appName || 'this app';
245
+ const userKey = state.userKey || state.userId || 'default';
246
+ const userEmail = state.userEmail || state.userName || 'Verified Account';
247
+ const bioLabel = this.detectBiometricLabel();
248
+
249
+ const backdrop = document.createElement('div');
250
+ backdrop.id = 'universal-passkey-prompt-backdrop';
251
+ backdrop.setAttribute('role', 'dialog');
252
+ backdrop.setAttribute('aria-modal', 'true');
253
+ backdrop.setAttribute('aria-labelledby', 'universal-passkey-prompt-title');
254
+ backdrop.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(15,23,42,0.68);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);display:flex;align-items:flex-end;justify-content:center;padding:0;opacity:0;transition:opacity 0.22s ease-out;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
255
+
256
+ const styleTag = document.createElement('style');
257
+ styleTag.textContent = `
258
+ @media (min-width: 640px) {
259
+ #universal-passkey-prompt-backdrop { align-items: center !important; padding: 16px !important; }
260
+ #universal-passkey-prompt-card { border-radius: 24px !important; max-width: 420px !important; transform: translateY(8px); }
261
+ }
262
+ @keyframes passkeySpin { to { transform: rotate(360deg); } }
263
+ `;
264
+ backdrop.appendChild(styleTag);
265
+
266
+ const card = document.createElement('div');
267
+ card.id = 'universal-passkey-prompt-card';
268
+ card.style.cssText = 'width:100%;max-width:480px;background:linear-gradient(180deg,#0f172a 0%,#1e293b 100%);color:#f8fafc;border:1px solid rgba(148,163,184,0.22);border-radius:24px 24px 0 0;padding:24px 24px calc(24px + env(safe-area-inset-bottom,0px));box-shadow:0 25px 50px -12px rgba(0,0,0,0.65);transform:translateY(16px);transition:transform 0.25s cubic-bezier(0.16,1,0.3,1);box-sizing:border-box;';
269
+
270
+ card.innerHTML = `
271
+ <div style="width:40px;height:4px;background:rgba(148,163,184,0.35);border-radius:999px;margin:0 auto 16px;"></div>
272
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
273
+ <div style="display:inline-flex;align-items:center;gap:10px;background:rgba(16,185,129,0.14);border:1px solid rgba(16,185,129,0.35);padding:6px 12px;border-radius:999px;">
274
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
275
+ <path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
276
+ </svg>
277
+ <span style="font-size:12px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:#6ee7b7;white-space:nowrap;">Biometric Security</span>
278
+ </div>
279
+ <span id="universal-passkey-account-pill" style="font-size:12px;color:#94a3b8;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
280
+ </div>
281
+ <h2 id="universal-passkey-prompt-title" style="margin:0 0 8px;font-size:22px;line-height:1.25;font-weight:800;letter-spacing:-0.02em;color:#ffffff;">Activate Biometric Sign-In</h2>
282
+ <p id="universal-passkey-prompt-desc" style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#cbd5e1;"></p>
283
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:18px;">
284
+ <div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
285
+ <div style="font-size:12px;font-weight:700;color:#f8fafc;">1-Tap Access</div>
286
+ <div style="font-size:11px;color:#94a3b8;margin-top:2px;">Instant sign-in</div>
287
+ </div>
288
+ <div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
289
+ <div style="font-size:12px;font-weight:700;color:#f8fafc;">Device-Bound</div>
290
+ <div style="font-size:11px;color:#94a3b8;margin-top:2px;">Private enclave</div>
291
+ </div>
292
+ <div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
293
+ <div style="font-size:12px;font-weight:700;color:#f8fafc;">Phishing-Proof</div>
294
+ <div style="font-size:11px;color:#94a3b8;margin-top:2px;">FIDO2 standard</div>
295
+ </div>
296
+ </div>
297
+ <div id="universal-passkey-prompt-feedback" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:10px 12px;border-radius:12px;font-size:13px;line-height:1.4;"></div>
298
+ <div style="display:flex;flex-direction:column;gap:10px;">
299
+ <button type="button" id="universal-passkey-activate-btn" style="min-height:48px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:14px;border:none;background:linear-gradient(135deg,#10b981 0%,#059669 100%);color:#ffffff;font-size:15px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;box-shadow:0 10px 20px -5px rgba(16,185,129,0.4);transition:transform 0.15s ease,filter 0.15s ease;">
300
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
301
+ <path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
302
+ </svg>
303
+ <span id="universal-passkey-activate-label"></span>
304
+ </button>
305
+ <button type="button" id="universal-passkey-later-btn" style="min-height:44px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:10px 16px;border-radius:12px;border:1px solid rgba(148,163,184,0.22);background:transparent;color:#cbd5e1;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:background 0.15s ease,color 0.15s ease;">
306
+ Maybe Later
307
+ </button>
308
+ </div>
309
+ `;
310
+
311
+ backdrop.appendChild(card);
312
+ document.body.appendChild(backdrop);
313
+
314
+ const accountPill = card.querySelector('#universal-passkey-account-pill');
315
+ if (accountPill) {
316
+ accountPill.textContent = userEmail;
317
+ accountPill.setAttribute('title', userEmail);
318
+ }
319
+ const descEl = card.querySelector('#universal-passkey-prompt-desc');
320
+ if (descEl) {
321
+ descEl.textContent = `Sign in to ${appName} instantly next time using your device's recognised biometric sensor (${bioLabel}). Your biometric data never leaves this device.`;
322
+ }
323
+ const activateLabelEl = card.querySelector('#universal-passkey-activate-label');
324
+ if (activateLabelEl) {
325
+ activateLabelEl.textContent = `Activate ${bioLabel}`;
326
+ }
327
+
328
+ requestAnimationFrame(() => {
329
+ backdrop.style.opacity = '1';
330
+ card.style.transform = 'translateY(0)';
331
+ });
332
+
333
+ const closePrompt = () => {
334
+ backdrop.style.opacity = '0';
335
+ card.style.transform = 'translateY(16px)';
336
+ setTimeout(() => {
337
+ if (backdrop.parentNode) {
338
+ backdrop.parentNode.removeChild(backdrop);
339
+ }
340
+ }, 220);
341
+ };
342
+
343
+ const showFeedback = (msg, isSuccess = false) => {
344
+ const fb = card.querySelector('#universal-passkey-prompt-feedback');
345
+ if (!fb) return;
346
+ fb.style.display = 'block';
347
+ fb.style.background = isSuccess ? 'rgba(16,185,129,0.18)' : 'rgba(245,158,11,0.16)';
348
+ fb.style.border = isSuccess ? '1px solid rgba(16,185,129,0.4)' : '1px solid rgba(245,158,11,0.38)';
349
+ fb.style.color = isSuccess ? '#a7f3d0' : '#fde68a';
350
+ fb.textContent = msg;
351
+ };
352
+
353
+ const activateBtn = card.querySelector('#universal-passkey-activate-btn');
354
+ const laterBtn = card.querySelector('#universal-passkey-later-btn');
355
+
356
+ if (laterBtn) {
357
+ laterBtn.addEventListener('click', () => {
358
+ this.snoozeActivationPrompt(userKey, 72);
359
+ closePrompt();
360
+ });
361
+ }
362
+
363
+ if (activateBtn) {
364
+ const originalHTML = activateBtn.innerHTML;
365
+ activateBtn.addEventListener('click', async () => {
366
+ if (activateBtn.disabled) return;
367
+ activateBtn.disabled = true;
368
+ activateBtn.style.opacity = '0.85';
369
+ activateBtn.style.cursor = 'wait';
370
+ activateBtn.innerHTML = `
371
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="animation:passkeySpin 0.8s linear infinite;" aria-hidden="true">
372
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
373
+ </svg>
374
+ <span>Authorising Biometrics...</span>
375
+ `;
376
+
377
+ const csrfMeta = document.querySelector('meta[name="csrf-token"]');
378
+ const headers = {
379
+ 'Accept': 'application/json',
380
+ 'Content-Type': 'application/json',
381
+ 'X-Requested-With': 'XMLHttpRequest'
382
+ };
383
+ if (csrfMeta && csrfMeta.content) {
384
+ headers['X-CSRF-TOKEN'] = csrfMeta.content;
385
+ }
386
+
387
+ try {
388
+ const optRes = await fetch(options.optionsEndpoint || '/webauthn/register/options', {
389
+ method: 'POST',
390
+ headers,
391
+ credentials: 'same-origin',
392
+ body: JSON.stringify({ role: state.userRole || 'users' })
393
+ });
394
+ const optJson = await optRes.json();
395
+ const creationOptions = optJson.token || optJson.data || optJson;
396
+ if (!optRes.ok || !creationOptions || !creationOptions.challenge) {
397
+ throw new Error(optJson.message || 'Unable to initialise biometric challenge.');
398
+ }
399
+
400
+ const credentialPayload = await this.registerDevice(creationOptions);
401
+ credentialPayload.role = state.userRole || 'users';
402
+ credentialPayload.deviceName = bioLabel;
403
+
404
+ const verifyRes = await fetch(options.registerEndpoint || '/webauthn/register', {
405
+ method: 'POST',
406
+ headers,
407
+ credentials: 'same-origin',
408
+ body: JSON.stringify(credentialPayload)
409
+ });
410
+ const verifyJson = await verifyRes.json();
411
+ if (!verifyRes.ok && verifyJson.status !== 'success') {
412
+ throw new Error(verifyJson.message || 'Failed to verify biometric passkey.');
413
+ }
414
+
415
+ this.markEnrolled(userKey);
416
+ showFeedback('Biometric Passkey activated! You can now sign in with 1 tap.', true);
417
+ setTimeout(closePrompt, 1100);
418
+ } catch (err) {
419
+ if (err && err.code === 'InvalidStateError') {
420
+ // Self-healing: authenticator already holds a credential for this user/RP
421
+ this.markEnrolled(userKey);
422
+ showFeedback('Biometric Passkey is already active on this device.', true);
423
+ setTimeout(closePrompt, 1100);
424
+ return;
425
+ }
426
+ activateBtn.disabled = false;
427
+ activateBtn.style.opacity = '1';
428
+ activateBtn.style.cursor = 'pointer';
429
+ activateBtn.innerHTML = originalHTML;
430
+ const rehydratedLabel = activateBtn.querySelector('#universal-passkey-activate-label');
431
+ if (rehydratedLabel) {
432
+ rehydratedLabel.textContent = `Activate ${bioLabel}`;
433
+ }
434
+
435
+ let msg = 'Biometric verification was cancelled or did not complete. Tap Activate whenever you are ready to try again.';
436
+ if (err) {
437
+ if (err.name === 'SecurityError' || err.code === 'SecurityError') {
438
+ msg = 'Passkey registration requires HTTPS or a valid domain origin.';
439
+ } else if (err.name === 'NotSupportedError' || err.code === 'NotSupportedError') {
440
+ msg = 'Biometric authentication is not supported or enabled on this browser.';
441
+ } else if (err.message && err.name !== 'NotAllowedError' && err.code !== 'NotAllowedError') {
442
+ msg = `${err.message}. Tap Activate to try again.`;
443
+ }
444
+ }
445
+ showFeedback(msg, false);
446
+ }
447
+ });
448
+ }
449
+
450
+ return backdrop;
451
+ }
452
+
453
+ /**
454
+ * Automatically evaluates post-login state and shows the Biometric Passkey Activation UI if eligible.
455
+ * @param {Object} [customState]
456
+ * @returns {Promise<boolean>}
457
+ */
458
+ static async initPostLoginPrompt(customState = null) {
459
+ const state = customState || (typeof window !== 'undefined' ? window.__WEBAUTHN_PROMPT_STATE__ : null);
460
+ if (!this.shouldShowActivationPrompt(state)) {
461
+ return false;
462
+ }
463
+ const available = await this.isPlatformAuthenticatorAvailable();
464
+ if (!available) {
465
+ return false;
466
+ }
467
+ this.renderPromptModal(state);
468
+ return true;
469
+ }
125
470
  }